39 files
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user