This commit is contained in:
Home Assistant Version Control
2026-08-02 15:33:12 +00:00
parent 60dd13b52c
commit 2ce8792155
39 changed files with 1992 additions and 157 deletions
+368 -23
View File
@@ -58,6 +58,7 @@ from .const import (
TOOLS_ENTRY_LEGACY_TITLE,
TOOLS_ENTRY_TITLE,
YAML_KEY_DEFAULT_POST_ACTION,
YAML_KEY_DENYLIST,
YAML_KEY_POST_ACTIONS,
)
from .websocket_api import async_register_commands
@@ -80,6 +81,8 @@ SERVICE_EDIT_YAML_CONFIG = "edit_yaml_config"
SERVICE_GET_CALLER_TOKEN = "get_caller_token"
SERVICE_GET_ALLOWED_PATHS = "get_allowed_paths"
SERVICE_SET_ALLOWED_PATHS = "set_allowed_paths"
SERVICE_GET_EXTRA_YAML_KEYS = "get_extra_yaml_keys"
SERVICE_SET_EXTRA_YAML_KEYS = "set_extra_yaml_keys"
# Read-only access to pre-#1579 YAML backups in .ha_mcp_tools_backups/, so the
# shared edits-backup interface can list/view/diff/restore them (#1579). These
# historical artifacts predate the fold into the shared store; new writes no
@@ -105,6 +108,18 @@ _ALLOWED_PATHS_STORAGE_KEY = f"{DOMAIN}_allowed_paths"
_ALLOWED_PATHS_STORAGE_VERSION = 1
_HASS_DATA_ALLOWED_PATHS_KEY = "allowed_paths"
# User-configurable extra YAML write keys (#1887). Same store-per-concern
# rationale as the allowed paths above: a separate Store, loaded into hass.data
# at setup and updated in place by set_extra_yaml_keys so enforcement picks up
# changes with no HA restart. This is the component-owned half of the setting;
# the ha-mcp server also carries its own HA_MCP_EXTRA_YAML_KEYS, and the
# effective write allowlist is the union of the two (the server reads this store
# via get_extra_yaml_keys). YAML_KEY_DENYLIST members are stripped on the way in
# and re-checked at enforcement, so the store can never widen the deny floor.
_EXTRA_YAML_KEYS_STORAGE_KEY = f"{DOMAIN}_extra_yaml_keys"
_EXTRA_YAML_KEYS_STORAGE_VERSION = 1
_HASS_DATA_EXTRA_YAML_KEYS_KEY = "extra_yaml_keys"
# Service schemas
SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
{
@@ -130,6 +145,15 @@ SERVICE_EDIT_YAML_CONFIG_SCHEMA = vol.Schema(
vol.Optional("disabled_packages_keys", default=list): vol.All(
cv.ensure_list, [cv.string]
),
# Caller-provided extra top-level keys the operator has opted into
# on top of ALLOWED_YAML_KEYS (#1887). Additive only: the handler
# filters YAML_KEY_DENYLIST out before use, so a caller cannot
# unlock a trust-boundary key by sending it here. Empty list (the
# default) means the built-in allowlist applies unchanged, which
# is also what a caller that predates this field produces.
vol.Optional("extra_allowed_keys", default=list): vol.All(
cv.ensure_list, [cv.string]
),
# Two-step preview/confirm flow (#1720). Both optional with
# old-behavior defaults so a pre-confirm-flow server (which never
# sends them) still gets an immediate write.
@@ -208,6 +232,24 @@ SERVICE_SET_ALLOWED_PATHS_SCHEMA = vol.Schema(
}
)
# get_extra_yaml_keys / set_extra_yaml_keys back the component's own options
# flow and the ha-mcp settings UI (#1887). Both are caller-token + admin gated,
# matching the allowed-paths pair. set_extra_yaml_keys receives the FULL
# replacement list; the handler strips whitespace/empties and drops any
# YAML_KEY_DENYLIST member, reporting drops in ``rejected``.
SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
{
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
}
)
SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA = vol.Schema(
{
vol.Optional("keys", default=list): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
}
)
SERVICE_LIST_LEGACY_BACKUPS_SCHEMA = vol.Schema(
{
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
@@ -335,6 +377,128 @@ async def _save_allowed_paths(hass: HomeAssistant, paths: list[str]) -> None:
await store.async_save({"paths": paths})
def _normalize_extra_yaml_keys(raw: Any) -> tuple[list[str], list[Any]]:
"""Clean a candidate extra-YAML-keys list into ``(kept, dropped)`` (#1887).
Mirrors the server's ``parse_extra_yaml_write_keys`` (strip whitespace, drop
empties, dedup, sort) and additionally drops any ``YAML_KEY_DENYLIST``
member, so the component store can never widen the deny floor. Non-string
and blank entries are dropped too. ``dropped`` collects every rejected
entry for reporting/logging. Case is preserved: HA's own top-level domain
lookup is exact-case, so a mis-cased key never reaches a real integration.
"""
kept: list[str] = []
dropped: list[Any] = []
seen: set[str] = set()
for entry in raw if isinstance(raw, list) else []:
if not isinstance(entry, str):
dropped.append(entry)
continue
key = entry.strip()
if not key or key in YAML_KEY_DENYLIST:
dropped.append(entry)
continue
if key not in seen:
seen.add(key)
kept.append(key)
return sorted(kept), dropped
async def _load_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
"""Return the persisted user-configurable extra YAML write keys (#1887).
Fail-safe like :func:`_load_allowed_paths`: a corrupt/unreadable or
hand-edited store never propagates out of ``async_setup_entry``. Every entry
is re-validated through :func:`_normalize_extra_yaml_keys`, so a denylisted
or malformed key can never load into ``hass.data`` (defense in depth - the
deny floor is also re-checked at enforcement). Empty list on first run or a
malformed store.
"""
store: Store = Store(
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
)
try:
data = await store.async_load()
except Exception:
_LOGGER.warning(
"ha_mcp_tools: could not load the extra-YAML-keys store; ignoring "
"it and granting no extra write keys.",
exc_info=True,
)
return []
if not isinstance(data, dict):
return []
raw = data.get("keys")
if raw is not None and not isinstance(raw, list):
_LOGGER.warning(
"ha_mcp_tools extra-YAML-keys store is malformed (keys is %s, "
"expected list); ignoring it.",
type(raw).__name__,
)
return []
kept, dropped = _normalize_extra_yaml_keys(raw)
if dropped:
_LOGGER.warning(
"ha_mcp_tools: dropped %d invalid entr%s from the persisted "
"extra-YAML-keys store: %r",
len(dropped),
"y" if len(dropped) == 1 else "ies",
dropped,
)
return kept
async def _save_extra_yaml_keys(hass: HomeAssistant, keys: list[str]) -> None:
"""Persist the user-configurable extra YAML write keys to .storage."""
store: Store = Store(
hass, _EXTRA_YAML_KEYS_STORAGE_VERSION, _EXTRA_YAML_KEYS_STORAGE_KEY
)
await store.async_save({"keys": keys})
async def _apply_allowed_paths(
hass: HomeAssistant, raw_paths: Any
) -> tuple[list[str], list[Any]]:
"""Normalize, persist, and hot-swap the extra directories (#1567, #1887).
Shared by the ``set_allowed_paths`` service and the tools-entry options flow
so both edit the store through one validated path. Each entry runs through
:func:`_normalize_extra_dir`; traversal / out-of-config / deny-floor entries
are dropped into ``rejected``. Persists to .storage and updates hass.data so
enforcement applies live. Returns ``(kept, rejected)``.
"""
config_dir = Path(hass.config.config_dir)
normalized: list[str] = []
rejected: list[Any] = []
for entry in raw_paths if isinstance(raw_paths, list) else []:
norm = (
_normalize_extra_dir(entry, config_dir) if isinstance(entry, str) else None
)
if norm is None:
rejected.append(entry)
elif norm not in normalized:
normalized.append(norm)
await _save_allowed_paths(hass, normalized)
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
return normalized, rejected
async def _apply_extra_yaml_keys(
hass: HomeAssistant, raw_keys: Any
) -> tuple[list[str], list[Any]]:
"""Normalize, persist, and hot-swap the extra YAML write keys (#1887).
Shared by the ``set_extra_yaml_keys`` service and the tools-entry options
flow. Delegates validation to :func:`_normalize_extra_yaml_keys` (strip,
dedup, sort, drop denylist). Persists to .storage and updates hass.data so
enforcement applies live. Returns ``(kept, rejected)``.
"""
normalized, rejected = _normalize_extra_yaml_keys(raw_keys)
await _save_extra_yaml_keys(hass, normalized)
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_EXTRA_YAML_KEYS_KEY] = normalized
return normalized, rejected
def _unified_diff(before: str, after: str, rel_path: str, max_lines: int = 200) -> str:
"""Unified diff of a prospective write, capped for response size."""
lines = list(
@@ -2095,7 +2259,10 @@ def _build_edit_yaml_config_handler(
# Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check)
kind, path_parts, path_err = _parse_and_validate_yaml_path(
yaml_path, is_package=is_package, is_theme=is_theme
yaml_path,
is_package=is_package,
is_theme=is_theme,
extra_allowed_keys=_effective_extra_allowed_keys(hass, call),
)
if path_err is not None:
return {"success": False, "error": path_err}
@@ -2218,18 +2385,59 @@ def _validate_lovelace_dashboard_path(
return "lovelace_dashboard", parts, None
def _caller_extra_allowed_keys(call: ServiceCall) -> frozenset[str]:
"""Return the operator-configured extra write keys for this call (#1887).
``YAML_KEY_DENYLIST`` members are dropped here so they can never widen the
``allowed`` set that the generic "not in the allowed list" rejection lists
for some *other* invalid key. A direct write to a denied key does not rely
on this drop: ``_parse_and_validate_yaml_path`` checks the denylist first
and returns the categorical floor message before any allow-set is
consulted. Keys already covered by the built-in sets are harmless
duplicates and stay.
"""
return frozenset(
key
for key in call.data.get("extra_allowed_keys", [])
if key and key not in YAML_KEY_DENYLIST
)
def _effective_extra_allowed_keys(
hass: HomeAssistant, call: ServiceCall
) -> frozenset[str]:
"""Union the per-call wire keys with the component-stored keys (#1887).
The ha-mcp server passes its own ``HA_MCP_EXTRA_YAML_KEYS`` on the wire; the
component's own options flow / settings store contributes
:func:`_current_extra_yaml_keys`. Both sources are denylist-filtered - the
wire in :func:`_caller_extra_allowed_keys`, the store on save/load - and
:func:`_parse_and_validate_yaml_path` re-checks the deny floor regardless,
so the union can never lift a forbidden key. The store side is filtered
again here purely as defense in depth.
"""
stored = frozenset(
key for key in _current_extra_yaml_keys(hass) if key not in YAML_KEY_DENYLIST
)
return _caller_extra_allowed_keys(call) | stored
def _parse_and_validate_yaml_path(
yaml_path: str,
*,
is_package: bool = False,
is_theme: bool = False,
extra_allowed_keys: frozenset[str] = frozenset(),
) -> tuple[str, tuple[str, ...], str | None]:
"""Parse and validate a yaml_path argument.
Three accepted shapes:
1. Single segment in ALLOWED_YAML_KEYS -> kind='single'
When ``is_package=True``, single segments in PACKAGES_ONLY_YAML_KEYS
(automation, script, scene) are also accepted.
(automation, script, scene) are also accepted. ``extra_allowed_keys``
adds operator-opted-in keys on top of ALLOWED_YAML_KEYS (#1887); it
overrides neither ``YAML_KEY_DENYLIST`` (filtered out before it gets
here) nor the packages-only restriction.
2. Exactly 'lovelace.dashboards.<url_path>' -> kind='lovelace_dashboard'
3. Single segment theme name (no dots) when ``is_theme=True`` -> kind='theme'
@@ -2256,13 +2464,41 @@ def _parse_and_validate_yaml_path(
# Shape 1: single key
if len(parts) == 1:
key = parts[0]
# The deny floor is checked before every single-key accept branch, so
# it holds even if a denied key is ever added to one of the allow
# sets. It deliberately does not cover the theme branch above: under
# ``is_theme`` the segment names a file in themes/, not a top-level
# configuration key, so the same word carries no trust-boundary
# meaning there.
if key in YAML_KEY_DENYLIST:
return (
"",
(),
(
f"Key '{yaml_path}' can never be edited through this "
"service: it redefines Home Assistant's own trust "
"boundary (authentication, proxy/CORS handling, or "
"frontend module loading). This floor cannot be lifted "
"by the extra-write-keys setting. Edit it by hand if "
"you really need to change it."
),
)
if key in ALLOWED_YAML_KEYS:
return "single", parts, None
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
return "single", parts, None
# Operator extra keys widen ALLOWED_YAML_KEYS only. They deliberately
# do NOT lift the packages-only restriction: those keys reach
# packages/*.yaml through the branch above (still governed by their
# per-key toggle) and stay rejected in configuration.yaml, so the
# storage-mode/YAML-mode collision guarantee holds however the
# operator fills the setting.
if key in extra_allowed_keys and key not in PACKAGES_ONLY_YAML_KEYS:
return "single", parts, None
# Reaching here means the key was not accepted. If it is a
# PACKAGES_ONLY key, we know is_package=False (otherwise the
# preceding branch would have returned) — emit the targeted
# PACKAGES_ONLY key, we know is_package=False (the packages branch
# would have returned, and the extra-keys branch excludes them
# precisely so this guidance still fires) emit the targeted
# "move it to a package file" guidance instead of the generic
# allowlist dump below.
if key in PACKAGES_ONLY_YAML_KEYS:
@@ -2278,9 +2514,9 @@ def _parse_and_validate_yaml_path(
),
)
allowed = (
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS | extra_allowed_keys
if is_package
else ALLOWED_YAML_KEYS
else ALLOWED_YAML_KEYS | extra_allowed_keys
)
return (
"",
@@ -2421,6 +2657,16 @@ def _current_extra_dirs(hass: HomeAssistant) -> list[str]:
return []
def _current_extra_yaml_keys(hass: HomeAssistant) -> list[str]:
"""Return the live component-configured extra YAML write keys from hass.data."""
domain_data = hass.data.get(DOMAIN)
if isinstance(domain_data, dict):
keys = domain_data.get(_HASS_DATA_EXTRA_YAML_KEYS_KEY)
if isinstance(keys, list):
return keys
return []
def _build_list_files_handler(
hass: HomeAssistant,
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
@@ -2520,9 +2766,11 @@ async def _shape_read_file_response(
# Apply special handling for specific files
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
# Mask secrets.yaml
# Mask secrets.yaml. Offloaded because the first make_yaml() call on a
# thread constructs a ruamel YAML instance, whose plugin discovery globs
# the site-packages tree — blocking work that must stay off the loop.
if normalized == "secrets.yaml":
content = _mask_secrets_content(content)
content = await hass.async_add_executor_job(_mask_secrets_content, content)
# Apply tail for log files
if normalized == "home-assistant.log":
@@ -2845,8 +3093,12 @@ def _build_get_caller_token_handler(
# uses everywhere else.
try:
integration = await async_get_integration(hass, DOMAIN)
if integration.version is None:
# Reads as None rather than raising; an unreadable version and
# an absent one deserve the same answer.
raise ValueError("the manifest carries no version")
version = str(integration.version)
except Exception as exc: # pragma: no cover — manifest sanity
except Exception as exc:
_LOGGER.warning(
"Could not read ha_mcp_tools manifest version for "
"get_caller_token response: %s",
@@ -2906,7 +3158,6 @@ def _build_set_allowed_paths_handler(
hass: HomeAssistant,
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
"""Build the handle_set_allowed_paths service handler."""
config_dir = Path(hass.config.config_dir)
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
"""Replace the user-configurable extra directories (issues #1567, #1586).
@@ -2929,18 +3180,9 @@ def _build_set_allowed_paths_handler(
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
"paths": [],
}
raw_paths = call.data.get("paths", [])
normalized: list[str] = []
rejected: list[str] = []
for entry in raw_paths:
norm = _normalize_extra_dir(entry, config_dir)
if norm is None:
rejected.append(entry)
elif norm not in normalized:
normalized.append(norm)
await _save_allowed_paths(hass, normalized)
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
normalized, rejected = await _apply_allowed_paths(
hass, call.data.get("paths", [])
)
_LOGGER.info(
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
normalized,
@@ -2955,6 +3197,77 @@ def _build_set_allowed_paths_handler(
return handle_set_allowed_paths
def _build_get_extra_yaml_keys_handler(
hass: HomeAssistant,
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
"""Build the handle_get_extra_yaml_keys service handler (#1887)."""
async def handle_get_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
"""Return the component-configured extra YAML write keys plus the
non-overridable deny floor.
Backs the component's own options flow and the ha-mcp settings UI, and
is how the server reads this store to union it with its own
``HA_MCP_EXTRA_YAML_KEYS``. Caller-token + admin gated, matching
get_allowed_paths.
"""
if not _caller_token_ok(hass, call):
return _unauthorized_response(SERVICE_GET_EXTRA_YAML_KEYS, keys=[])
if not await _caller_is_admin(hass, call):
return {
"success": False,
"error_code": "unauthorized",
"error": "ha_mcp_tools.get_extra_yaml_keys requires admin auth.",
"keys": [],
}
return {
"success": True,
"keys": _current_extra_yaml_keys(hass),
"deny_floor": sorted(YAML_KEY_DENYLIST),
}
return handle_get_extra_yaml_keys
def _build_set_extra_yaml_keys_handler(
hass: HomeAssistant,
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
"""Build the handle_set_extra_yaml_keys service handler (#1887)."""
async def handle_set_extra_yaml_keys(call: ServiceCall) -> ServiceResponse:
"""Replace the component-configured extra YAML write keys.
Receives the FULL replacement list. Each entry is stripped and
validated; blanks and ``YAML_KEY_DENYLIST`` members are dropped and
reported in ``rejected``. Persists to .storage AND updates hass.data so
enforcement applies live with no HA restart. Caller-token + admin gated.
"""
if not _caller_token_ok(hass, call):
return _unauthorized_response(SERVICE_SET_EXTRA_YAML_KEYS, keys=[])
if not await _caller_is_admin(hass, call):
return {
"success": False,
"error_code": "unauthorized",
"error": "ha_mcp_tools.set_extra_yaml_keys requires admin auth.",
"keys": [],
}
normalized, rejected = await _apply_extra_yaml_keys(
hass, call.data.get("keys", [])
)
_LOGGER.info(
"Updated ha_mcp_tools extra YAML write keys: %s (%d rejected)",
normalized,
len(rejected),
)
return {
"success": True,
"keys": normalized,
"rejected": rejected,
}
return handle_set_extra_yaml_keys
def _build_list_legacy_backups_handler(
hass: HomeAssistant,
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
@@ -3063,6 +3376,13 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
_HASS_DATA_ALLOWED_PATHS_KEY
] = await _load_allowed_paths(hass)
# Load the component-configured extra YAML write keys (#1887) into hass.data
# so enforcement reads them with no I/O. set_extra_yaml_keys updates this in
# place, so changes apply live (no HA restart).
hass.data.setdefault(DOMAIN, {})[
_HASS_DATA_EXTRA_YAML_KEYS_KEY
] = await _load_extra_yaml_keys(hass)
# One-time migration of pre-fix YAML backups out of the publicly-served
# www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure
# cannot prevent the integration from loading — the integration's
@@ -3132,6 +3452,8 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
handle_get_caller_token = _build_get_caller_token_handler(hass)
handle_get_allowed_paths = _build_get_allowed_paths_handler(hass)
handle_set_allowed_paths = _build_set_allowed_paths_handler(hass)
handle_get_extra_yaml_keys = _build_get_extra_yaml_keys_handler(hass)
handle_set_extra_yaml_keys = _build_set_extra_yaml_keys_handler(hass)
handle_list_legacy_backups = _build_list_legacy_backups_handler(hass)
handle_read_legacy_backup = _build_read_legacy_backup_handler(hass)
@@ -3200,6 +3522,22 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_EXTRA_YAML_KEYS,
handle_get_extra_yaml_keys,
schema=SERVICE_GET_EXTRA_YAML_KEYS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_EXTRA_YAML_KEYS,
handle_set_extra_yaml_keys,
schema=SERVICE_SET_EXTRA_YAML_KEYS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_LIST_LEGACY_BACKUPS,
@@ -3242,10 +3580,17 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
component_version = COMPONENT_VERSION
try:
integration = await async_get_integration(hass, DOMAIN)
if integration.version is None:
# A manifest without a version reads as None rather than raising,
# and ``str()`` would put the literal "None" on the device.
raise ValueError("the manifest carries no version")
component_version = str(integration.version)
except Exception as err:
_LOGGER.debug(
"Could not read the component version for the tools device: %s", err
"Could not read the component version for the tools device, using "
"the compiled-in %s: %s",
COMPONENT_VERSION,
err,
)
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,