348 files

This commit is contained in:
Home Assistant Version Control
2026-07-23 19:02:12 +00:00
parent 0ed1687503
commit 016af2e013
348 changed files with 12368 additions and 11689 deletions
+2 -1
View File
@@ -1000,7 +1000,8 @@ def _is_path_allowed_for_read(
Allowed:
- Files directly in config dir: configuration.yaml, automations.yaml, etc.
- Files in allowed directories: www/, themes/, custom_templates/
- Files in allowed directories: www/, themes/, custom_templates/,
dashboards/, blueprints/ (blueprints is read-only — issue #1965)
- Files matching patterns: <packages-folder>/*.yaml, custom_components/**/*.py
- User-configured extra directories (``extra_dirs``), granted read+write
(issue #1567)
+12 -3
View File
@@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools"
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
# capability negotiation — not this version — gates each WS command (see
# ``websocket_api.CAPABILITIES``).
COMPONENT_VERSION = "1.2.2"
COMPONENT_VERSION = "1.2.3"
# Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value
# means "tools" so the pre-existing services entry keeps working across the
@@ -40,8 +40,17 @@ TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools"
TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools"
MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0"
# Allowed directories for file operations (relative to config dir)
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# Allowed directories for file operations (relative to config dir).
# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not
# ALLOWED_WRITE_DIRS, so ha_write_file / ha_delete_file reject it (raw blueprint
# reads are safe: community YAML, no secrets — issue #1965). This is the default
# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a
# custom extra directory (issue #1567, see _current_extra_dirs) grants it
# read+write, since extra_dirs are honored on the write path too. Blueprint
# writes should instead go through ha_import_blueprint (which invokes the
# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed
# body; raw read is the escape hatch for the exact on-disk text.
ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"]
ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"]
# NON-OVERRIDABLE deny floor for the user-configurable extra read/write
@@ -1568,6 +1568,64 @@ _PENDING_INSTALL_DONE: threading.Event | None = None
_CACHED_IMPORT_VERSION: str | None = None
def _safe_invalidate_caches() -> None:
"""Run ``importlib.invalidate_caches()``, completing it if a finder breaks.
On Python 3.14 with ``homeassistant`` installed as a setuptools *editable*
package (the official HA container image), ``PathFinder.invalidate_caches()``
raises ``KeyError`` at its ``del sys.path_importer_cache[name]`` line: the
synthetic ``__editable__.<dist>.finder.__path_hook__`` placeholder is not an
absolute path, so CPython takes the ``del`` branch on a key an earlier
iteration already removed a CPython 3.14 bug (the ``del`` should be a
``pop``). That aborts ``importlib.invalidate_caches()`` partway and, before
this guard, crashed in-process server bring-up on every boot (issues #1891,
#1985).
On that ``KeyError`` we do CPython's own cleanup ourselves — prune the dead /
relative ``sys.path_importer_cache`` entries with ``pop`` instead of the
buggy ``del`` (the stale placeholder that trips the sweep is among them)
then re-run ``importlib.invalidate_caches()``. With the offending entries
gone the retry completes CPython's full sweep itself: every live path-entry
finder invalidated, the namespace-path epoch advanced, and the metadata
finder refreshed. Delegating the second pass keeps us off private internals
(no ``_NamespacePath`` / ``_path_isabs`` poking) and faithful to whatever the
running Python's ``invalidate_caches`` does. Recovery only ever runs on the
broken 3.14 path (the top-level call succeeds everywhere else); every
non-``KeyError`` still propagates.
The retry is itself guarded: a concurrent import on another HA-core thread
could re-add a stale placeholder in the window between the prune and the
retry, so a *second* ``KeyError`` is tolerated (logged, best-effort) rather
than re-raised a partial cache refresh must never re-crash bring-up, which
is the whole point of this helper. The recovery is logged at WARNING (it
recurs on every version check on an affected install), so it is visible for
diagnosis rather than a silent workaround.
"""
try:
importlib.invalidate_caches()
return
except KeyError as err:
_LOGGER.warning(
"importlib.invalidate_caches() raised KeyError from a broken "
"(setuptools editable / Python 3.14) finder; pruning stale "
"sys.path_importer_cache entries and retrying: %s",
err,
)
for name in list(sys.path_importer_cache):
if sys.path_importer_cache.get(name) is None or not os.path.isabs(name):
sys.path_importer_cache.pop(name, None)
try:
importlib.invalidate_caches()
except KeyError as err:
_LOGGER.warning(
"importlib.invalidate_caches() still raised KeyError after pruning "
"stale sys.path_importer_cache entries; continuing with a best-effort "
"cache state (a concurrent import may have re-added the placeholder): "
"%s",
err,
)
def _purge_ha_mcp_modules() -> None:
"""Drop every cached ``ha_mcp`` module so the next import loads fresh code.
@@ -1595,7 +1653,7 @@ def _purge_ha_mcp_modules() -> None:
return
for name in purged:
sys.modules.pop(name, None)
importlib.invalidate_caches()
_safe_invalidate_caches()
_LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged))
@@ -1608,7 +1666,7 @@ def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None:
provided, checks that channel first so stale metadata from a failed
best-effort conflicting uninstall cannot mask the package just installed.
"""
importlib.invalidate_caches()
_safe_invalidate_caches()
# Metadata alone is not proof: a channel switch's best-effort uninstall
# can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/
# tree belongs to whichever dist installed last). Require the import
@@ -1631,7 +1689,7 @@ def _dist_installed(dist_name: str) -> bool:
Invalidates the import caches first so a just-completed (un)install is seen.
"""
importlib.invalidate_caches()
_safe_invalidate_caches()
try:
importlib.metadata.version(dist_name)
except importlib.metadata.PackageNotFoundError:
@@ -1648,7 +1706,7 @@ def _installed_dist_version(dist_name: str) -> str | None:
the auto-update check compares the newest PyPI build against the version of
the channel actually installed.
"""
importlib.invalidate_caches()
_safe_invalidate_caches()
try:
return importlib.metadata.version(dist_name)
except importlib.metadata.PackageNotFoundError:
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [
"ruamel.yaml>=0.18.0"
],
"version": "1.2.2"
"version": "1.2.3"
}
+29 -3
View File
@@ -486,6 +486,13 @@ def _register_metadata_views(hass: HomeAssistant) -> None:
"""
if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY):
return
# Set the flag only AFTER every view registers (issue #1978): it must mean
# "the full bundle is bound", so a partial bind stays distinguishable from a
# complete one. Marking it bound early would let a later setup assign a
# provider and advertise discovery while some RFC metadata routes are still
# unbound — a 404 for the clients that probe them. On a partial bind the flag
# stays unset; the none-mode caller then fails open (the retry's duplicate
# register is caught harmlessly) while ha_auth/legacy fail closed.
for view in _metadata_views(hass):
hass.http.register_view(view)
hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True
@@ -735,9 +742,28 @@ async def async_register_webhook(
# login (issue #1969). Both view bundles bind at most once per
# HA session; the per-request resolvers gate them on this cfg,
# so a none<->ha_auth switch needs no restart.
_register_metadata_views(hass)
bind_autoapprove_views(hass)
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
#
# Fails OPEN, unlike ha_auth/legacy (issue #1978): none mode is
# intentionally unauthenticated, and this discovery is an
# enhancement layered on a webhook that (already registered
# above) otherwise always forwards. A failure here must NOT fall
# through to the outer teardown and take down a webhook the user
# configured to need no auth — it only means claude.ai's rare
# OAuth-discovery fallback goes unassisted. Mirrors the add-on's
# _setup_none_autoapprove. Provider is assigned last, so a
# partial bind leaves none-autoapprove inactive (plain proxy)
# rather than half-enabled.
try:
_register_metadata_views(hass)
bind_autoapprove_views(hass)
cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider()
except Exception:
_LOGGER.exception(
"MCP webhook: failed to set up none-mode auto-approve "
"discovery; continuing as a plain unauthenticated proxy "
"(the webhook still forwards — only claude.ai's rare "
"OAuth-discovery fallback is unassisted)."
)
except Exception:
# Never leave a live endpoint (or a leaked session) behind a failed
# auth-setup path. suppress: the ORIGINAL error must be what
@@ -185,6 +185,18 @@ class AutoApproveAuthorizeView(HomeAssistantView):
open-redirect gate, then issues a PKCE-bound one-time code and redirects
straight back to the client. No login page and no consent screen render, so
claude.ai's OAuth flow completes invisibly (issue #1969).
ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none
mode requires zero HA login so it consults neither the webhook id nor a
client identity. Anyone who knows the HA origin can therefore fill the
shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound
to the public claude.ai callback, at which point a *brand-new* connector's
handshake gets ``temporarily_unavailable`` until those codes expire
(``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no
data, and grants no access: completing the flow needs the PKCE verifier the
attacker never has, and the issued token is cosmetic (none mode ignores
bearers). The webhook URL itself keeps forwarding throughout only the rare
OAuth-discovery fallback for a *first* connect is briefly delayed.
"""
requires_auth = False
@@ -298,6 +310,11 @@ def bind_autoapprove_views(hass: HomeAssistant) -> None:
"""
if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
return
# Set the flag only AFTER both views register (issue #1978): see
# mcp_webhook._register_metadata_views. Marking the bundle bound before
# /token registers would let a later none-mode setup assign the provider and
# advertise OAuth with an unbound /token — a 404 on the token exchange. The
# flag must mean the full bundle succeeded; a partial bind leaves it unset.
hass.http.register_view(AutoApproveAuthorizeView(hass))
hass.http.register_view(AutoApproveTokenView(hass))
hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
@@ -436,7 +436,11 @@ class PKCECodeStore:
Returns True only for a live, unexpired code whose stored
``redirect_uri`` matches and whose ``code_challenge`` equals
``base64url(SHA-256(code_verifier))``. The code is popped (one-shot)
before any check that can fail, so a failed attempt still burns it.
once the verifier clears the cheap RFC 7636 shape guard below, so every
well-formed attempt burns it a wrong verifier, expired code, or
redirect mismatch still consumes the code. A malformed verifier is
rejected before the pop and does NOT burn the code, so a client that
sends a syntactically broken verifier can retry.
"""
# Validate the verifier shape per RFC 7636 §4.1 before doing any
# crypto. A confused client passing an empty/short verifier should be
+12 -8
View File
@@ -58,7 +58,10 @@ list_files:
fields:
path:
name: Path
description: Relative path from config directory (e.g., "www/", "themes/")
description: >-
Relative path from config directory. Allowed: www/, themes/,
custom_templates/, dashboards/, blueprints/, plus your configured
packages/ folder (e.g., "www/", "blueprints/automation").
required: true
example: "www/"
selector:
@@ -81,7 +84,8 @@ read_file:
Relative path from config directory. Allowed paths include
configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml,
secrets.yaml (values masked), home-assistant.log, www/**, themes/**,
custom_templates/**, packages/*.yaml, custom_components/**/*.py
custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml,
custom_components/**/*.py
required: true
example: "configuration.yaml"
selector:
@@ -119,13 +123,13 @@ read_file:
write_file:
name: Write File
description: Write a file to allowed directories (www/, themes/, custom_templates/).
description: Write a file to allowed directories (www/, themes/, custom_templates/, dashboards/).
fields:
path:
name: Path
description: >-
Relative path from config directory. Must be in www/, themes/, or
custom_templates/.
Relative path from config directory. Must be in www/, themes/,
custom_templates/, or dashboards/.
required: true
example: "www/custom.css"
selector:
@@ -155,13 +159,13 @@ write_file:
delete_file:
name: Delete File
description: Delete a file from allowed directories (www/, themes/, custom_templates/).
description: Delete a file from allowed directories (www/, themes/, custom_templates/, dashboards/).
fields:
path:
name: Path
description: >-
Relative path from config directory. Must be in www/, themes/, or
custom_templates/.
Relative path from config directory. Must be in www/, themes/,
custom_templates/, or dashboards/.
required: true
example: "www/old-file.css"
selector: