3630 lines
142 KiB
Python
3630 lines
142 KiB
Python
"""HA MCP Tools - Custom component for ha-mcp server.
|
||
|
||
Provides services that are not available through standard Home Assistant APIs,
|
||
enabling AI assistants to perform advanced operations like file management.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import difflib
|
||
import errno
|
||
import fnmatch
|
||
import hashlib
|
||
import logging
|
||
import os
|
||
import posixpath
|
||
import re
|
||
import secrets
|
||
import shutil
|
||
from collections.abc import Awaitable, Callable
|
||
from datetime import datetime
|
||
from io import StringIO
|
||
from pathlib import Path, PurePosixPath
|
||
from typing import Any
|
||
|
||
import voluptuous as vol
|
||
import yaml # type: ignore[import-untyped]
|
||
from homeassistant.components import persistent_notification
|
||
from homeassistant.config import async_check_ha_config_file
|
||
from homeassistant.config_entries import ConfigEntry
|
||
from homeassistant.core import (
|
||
HomeAssistant,
|
||
ServiceCall,
|
||
ServiceResponse,
|
||
SupportsResponse,
|
||
)
|
||
from homeassistant.helpers import config_validation as cv
|
||
from homeassistant.helpers import device_registry as dr
|
||
from homeassistant.helpers.storage import Store
|
||
from homeassistant.loader import async_get_integration
|
||
from ruamel.yaml import YAMLError
|
||
|
||
from .const import (
|
||
ALLOWED_READ_DIRS,
|
||
ALLOWED_VOLUME_ROOTS,
|
||
ALLOWED_WRITE_DIRS,
|
||
ALLOWED_YAML_CONFIG_FILES,
|
||
ALLOWED_YAML_KEYS,
|
||
COMPONENT_VERSION,
|
||
CONF_ENTRY_TYPE,
|
||
DASHBOARD_URL_PATH_PATTERN,
|
||
DENY_PATH_SEGMENTS,
|
||
DENY_READ_BASENAMES,
|
||
DOMAIN,
|
||
ENTRY_TYPE_SERVER,
|
||
ENTRY_TYPE_TOOLS,
|
||
PACKAGES_ONLY_YAML_KEYS,
|
||
RESERVED_DASHBOARD_URL_PATHS,
|
||
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
|
||
from .yaml_rt import (
|
||
apply_seq_indent,
|
||
detect_seq_indent,
|
||
make_yaml,
|
||
yaml_dumps,
|
||
yaml_jsonify,
|
||
)
|
||
|
||
_LOGGER = logging.getLogger(__name__)
|
||
|
||
# Service names
|
||
SERVICE_LIST_FILES = "list_files"
|
||
SERVICE_READ_FILE = "read_file"
|
||
SERVICE_WRITE_FILE = "write_file"
|
||
SERVICE_DELETE_FILE = "delete_file"
|
||
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
|
||
# longer land here.
|
||
SERVICE_LIST_LEGACY_BACKUPS = "list_legacy_backups"
|
||
SERVICE_READ_LEGACY_BACKUP = "read_legacy_backup"
|
||
|
||
# Caller-token auth (PR: restrict ha_mcp_tools.* to ha-mcp callers).
|
||
# ha-mcp injects this field in every service-call payload; non-ha-mcp callers
|
||
# (HA UI, automations, other integrations, the ha_call_service LLM bypass)
|
||
# omit it and are rejected with a structured unauthorized response.
|
||
CALLER_TOKEN_FIELD = "_ha_mcp_token"
|
||
_TOKEN_STORAGE_KEY = f"{DOMAIN}_auth"
|
||
_TOKEN_STORAGE_VERSION = 1
|
||
_HASS_DATA_TOKEN_KEY = "caller_token"
|
||
|
||
# User-configurable extra read/write directories (issue #1567). Persisted in a
|
||
# SEPARATE Store from the caller token so a corrupt/edited allowlist can never
|
||
# affect token bootstrap, and the security credential is never mixed with user
|
||
# config. Loaded into hass.data at setup and updated in place by
|
||
# set_allowed_paths so enforcement picks up changes with no HA restart.
|
||
_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(
|
||
{
|
||
vol.Required("file"): cv.string,
|
||
vol.Required("action"): vol.In(["add", "replace", "remove", "replace_file"]),
|
||
vol.Required("yaml_path"): cv.string,
|
||
vol.Optional("content"): cv.string,
|
||
# Back-compat shim: the component reaches users via HACS ahead of the
|
||
# server, so a 0.10.0 component runs against the prior stable server
|
||
# until that server's next release. Pre-7.9.0 servers still send
|
||
# "backup": true on every edit_yaml_config call; this strict
|
||
# (PREVENT_EXTRA) schema would reject it ("extra keys not allowed")
|
||
# and break ha_config_set_yaml for everyone in that window. Accept and
|
||
# ignore it. Removable once the minimum supported server is >= 7.9.0.
|
||
vol.Optional("backup"): cv.boolean,
|
||
# Caller-provided list of PACKAGES_ONLY_YAML_KEYS that the
|
||
# caller wants the component to reject. Empty list (the
|
||
# default) means no extra restrictions on top of the
|
||
# component's existing allowlist. ha-mcp populates this from
|
||
# its per-key Settings flags so the wrapper and the component
|
||
# stay symmetric — if ha-mcp's flag is off, the component also
|
||
# refuses, defending against bypass attempts.
|
||
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.
|
||
vol.Optional("require_confirm", default=False): cv.boolean,
|
||
vol.Optional("confirm_token"): cv.string,
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
SERVICE_LIST_FILES_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Required("path"): cv.string,
|
||
vol.Optional("pattern"): cv.string,
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
SERVICE_READ_FILE_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Required("path"): cv.string,
|
||
vol.Optional("tail_lines"): vol.Coerce(int),
|
||
# When set, also return the round-trip text of the YAML subtree at this
|
||
# dotted path under ``subtree`` (used by ha-mcp's per-edit auto-backup
|
||
# to snapshot the prior value before ha_config_set_yaml edits it, #1579).
|
||
vol.Optional("yaml_path"): cv.string,
|
||
# With ``yaml_path``, also return that subtree as JSON-safe parsed data
|
||
# under ``parsed`` (ha_config_get_yaml's include_parsed, #1788). HA tags
|
||
# are rendered to source form, never resolved.
|
||
vol.Optional("include_parsed", default=False): cv.boolean,
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
SERVICE_WRITE_FILE_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Required("path"): cv.string,
|
||
vol.Required("content"): cv.string,
|
||
vol.Optional("overwrite", default=False): cv.boolean,
|
||
vol.Optional("create_dirs", default=True): cv.boolean,
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
SERVICE_DELETE_FILE_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Required("path"): cv.string,
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
# get_caller_token is the bootstrap surface: ha-mcp does not know the token
|
||
# yet on first run, so this service intentionally does NOT require the token
|
||
# itself. HA's default admin-auth still applies to the service call.
|
||
SERVICE_GET_CALLER_TOKEN_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
# get_allowed_paths / set_allowed_paths back the ha-mcp settings UI's custom
|
||
# filesystem-directory editor (issues #1567, #1586). Both are caller-token +
|
||
# admin gated. set_allowed_paths receives the FULL replacement list (mirrors how
|
||
# disabled_packages_keys sends the whole set each call); the handler validates
|
||
# and drops any entry that hits the deny floor, or that escapes the config dir
|
||
# without being one of the fixed HAOS sibling-volume roots (#1586).
|
||
SERVICE_GET_ALLOWED_PATHS_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
SERVICE_SET_ALLOWED_PATHS_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Optional("paths", default=list): vol.All(cv.ensure_list, [cv.string]),
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
# 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,
|
||
}
|
||
)
|
||
|
||
SERVICE_READ_LEGACY_BACKUP_SCHEMA = vol.Schema(
|
||
{
|
||
vol.Required("filename"): cv.string,
|
||
vol.Optional(CALLER_TOKEN_FIELD): cv.string,
|
||
}
|
||
)
|
||
|
||
# Files that are allowed to be read (even if not in ALLOWED_READ_DIRS)
|
||
ALLOWED_READ_FILES = [
|
||
"configuration.yaml",
|
||
"automations.yaml",
|
||
"scripts.yaml",
|
||
"scenes.yaml",
|
||
"secrets.yaml",
|
||
"home-assistant.log",
|
||
]
|
||
|
||
# Default tail lines for log files
|
||
DEFAULT_LOG_TAIL_LINES = 1000
|
||
|
||
|
||
async def _load_or_create_caller_token(hass: HomeAssistant) -> str:
|
||
"""Return the persisted caller token, generating + saving one on first use.
|
||
|
||
The token authorizes a caller as ha-mcp. It's stored under
|
||
``.storage/ha_mcp_tools_auth`` and remains stable across restarts so the
|
||
ha-mcp server can re-bootstrap without user intervention.
|
||
|
||
A corrupt/unreadable store must NOT propagate out of async_setup_entry and
|
||
take down the integration: on a load failure we log and fall through to
|
||
generating a fresh token (same path as first run), overwriting the bad
|
||
blob. ha-mcp transparently re-bootstraps the new token via its
|
||
unauthorized-retry, so the only cost is a one-time token rotation.
|
||
"""
|
||
store: Store = Store(hass, _TOKEN_STORAGE_VERSION, _TOKEN_STORAGE_KEY)
|
||
try:
|
||
data = await store.async_load()
|
||
except Exception:
|
||
_LOGGER.warning(
|
||
"ha_mcp_tools: could not load the caller-token store; generating a "
|
||
"fresh token and overwriting it.",
|
||
exc_info=True,
|
||
)
|
||
data = None
|
||
if isinstance(data, dict):
|
||
existing = data.get("token")
|
||
if isinstance(existing, str) and existing:
|
||
return existing
|
||
token = secrets.token_urlsafe(32)
|
||
await store.async_save({"token": token})
|
||
return token
|
||
|
||
|
||
async def _load_allowed_paths(hass: HomeAssistant) -> list[str]:
|
||
"""Return the persisted user-configurable extra directories.
|
||
|
||
Each stored entry is re-validated through :func:`_normalize_extra_dir`, so a
|
||
hand-edited or corrupted store can never load a traversal / deny-floor /
|
||
out-of-config entry into ``hass.data`` (defense in depth — the deny floor is
|
||
also re-checked at enforcement time). Anything dropped is logged so a
|
||
"my custom directories disappeared" case is one grep away. Empty list on
|
||
first run or a malformed store — fail safe to "no extra access" rather than
|
||
raising, mirroring :func:`_load_or_create_caller_token`.
|
||
"""
|
||
store: Store = Store(
|
||
hass, _ALLOWED_PATHS_STORAGE_VERSION, _ALLOWED_PATHS_STORAGE_KEY
|
||
)
|
||
try:
|
||
data = await store.async_load()
|
||
except Exception:
|
||
# Honour the documented fail-safe contract: a corrupt/unreadable
|
||
# allowed-paths blob must NOT propagate out of async_setup_entry and
|
||
# take down the integration. Log loudly and fall back to no extra
|
||
# access.
|
||
_LOGGER.warning(
|
||
"ha_mcp_tools: could not load the allowed-paths store; ignoring it "
|
||
"and granting no extra directories.",
|
||
exc_info=True,
|
||
)
|
||
return []
|
||
if not isinstance(data, dict):
|
||
return []
|
||
raw = data.get("paths")
|
||
if not isinstance(raw, list):
|
||
if raw is not None:
|
||
_LOGGER.warning(
|
||
"ha_mcp_tools allowed-paths store is malformed (paths is %s, "
|
||
"expected list); ignoring it.",
|
||
type(raw).__name__,
|
||
)
|
||
return []
|
||
config_dir = Path(hass.config.config_dir)
|
||
normalized: list[str] = []
|
||
dropped: list[Any] = []
|
||
for entry in raw:
|
||
norm = (
|
||
_normalize_extra_dir(entry, config_dir) if isinstance(entry, str) else None
|
||
)
|
||
if norm is None:
|
||
dropped.append(entry)
|
||
elif norm not in normalized:
|
||
normalized.append(norm)
|
||
if dropped:
|
||
_LOGGER.warning(
|
||
"ha_mcp_tools: dropped %d invalid entr%s from the persisted "
|
||
"allowed-paths store: %r",
|
||
len(dropped),
|
||
"y" if len(dropped) == 1 else "ies",
|
||
dropped,
|
||
)
|
||
return normalized
|
||
|
||
|
||
async def _save_allowed_paths(hass: HomeAssistant, paths: list[str]) -> None:
|
||
"""Persist the user-configurable extra directories to .storage."""
|
||
store: Store = Store(
|
||
hass, _ALLOWED_PATHS_STORAGE_VERSION, _ALLOWED_PATHS_STORAGE_KEY
|
||
)
|
||
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(
|
||
difflib.unified_diff(
|
||
before.splitlines(keepends=True),
|
||
after.splitlines(keepends=True),
|
||
fromfile=f"{rel_path} (before)",
|
||
tofile=f"{rel_path} (after)",
|
||
)
|
||
)
|
||
if len(lines) > max_lines:
|
||
omitted = len(lines) - max_lines
|
||
lines = lines[:max_lines] + [f"... diff truncated ({omitted} more lines)\n"]
|
||
return "".join(lines)
|
||
|
||
|
||
def _confirm_token(normalized: str, new_content: str) -> str:
|
||
"""Stateless confirm token: hash of target path + exact bytes to write.
|
||
|
||
Re-derived on the confirm call from CURRENT disk state, so any change
|
||
to the file between preview and confirm invalidates the token
|
||
(optimistic locking, same philosophy as utils/config_hash.py).
|
||
"""
|
||
return hashlib.sha256(f"{normalized}\n{new_content}".encode()).hexdigest()[:16]
|
||
|
||
|
||
def _count_reindented_lines(diff_text: str) -> int:
|
||
"""Count untouched lines that only changed leading whitespace.
|
||
|
||
A ``-``/``+`` pair whose stripped bodies match is a pure re-indent —
|
||
collateral from normalizing a mixed-style file to one sequence style
|
||
(ruamel supports a single style per dump). Multiset matching so
|
||
repeated identical lines don't over-count.
|
||
"""
|
||
removed: dict[str, int] = {}
|
||
added: dict[str, int] = {}
|
||
for line in diff_text.splitlines():
|
||
if line.startswith("---") or line.startswith("+++"):
|
||
continue
|
||
if line.startswith("-"):
|
||
body = line[1:]
|
||
removed[body.strip()] = removed.get(body.strip(), 0) + 1
|
||
elif line.startswith("+"):
|
||
body = line[1:]
|
||
added[body.strip()] = added.get(body.strip(), 0) + 1
|
||
return sum(min(n, added.get(key, 0)) for key, n in removed.items() if key)
|
||
|
||
|
||
def _reindent_warning(count: int) -> str:
|
||
"""Warning text for re-indented untouched lines (see ``diff``)."""
|
||
return (
|
||
f"{count} line(s) outside the requested edit were re-indented: the "
|
||
"file mixes sequence-indent styles and the serializer normalizes to "
|
||
"the first-detected style. Values are unchanged (guarded); review "
|
||
"the diff if the whitespace matters."
|
||
)
|
||
|
||
|
||
def _unauthorized_response(service_name: str, **extra: Any) -> dict[str, Any]:
|
||
"""Structured 'unauthorized' response.
|
||
|
||
ha-mcp clients detect this via ``error_code == "unauthorized"`` and
|
||
re-fetch the token before retrying.
|
||
"""
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Unauthorized: caller token missing or invalid for "
|
||
f"{DOMAIN}.{service_name}. This service is restricted to the "
|
||
"ha-mcp server; other callers should not invoke it directly."
|
||
),
|
||
"error_code": "unauthorized",
|
||
**extra,
|
||
}
|
||
|
||
|
||
def _caller_token_ok(hass: HomeAssistant, call: ServiceCall) -> bool:
|
||
"""Return True if the caller presented the configured token."""
|
||
domain_data = hass.data.get(DOMAIN)
|
||
expected = (
|
||
domain_data.get(_HASS_DATA_TOKEN_KEY) if isinstance(domain_data, dict) else None
|
||
)
|
||
presented = call.data.get(CALLER_TOKEN_FIELD)
|
||
# token_urlsafe(32) is 256-bit; the timing-side-channel risk is already
|
||
# negligible, but secrets.compare_digest is the right reflex regardless.
|
||
if not isinstance(expected, str) or not isinstance(presented, str):
|
||
return False
|
||
return secrets.compare_digest(expected, presented)
|
||
|
||
|
||
async def _caller_is_admin(hass: HomeAssistant, call: ServiceCall) -> bool:
|
||
"""Return True if the caller is an admin user (or a no-user-context call).
|
||
|
||
HA's service registry has no built-in admin requirement — `Service`
|
||
has no admin flag, `async_call` performs no permission check, and
|
||
WS / REST `call_service` lack `@require_admin`. Gate explicitly here.
|
||
|
||
Calls without `context.user_id` (system-internal events) are treated
|
||
as trusted, matching HA's `async_admin_handler_factory` convention.
|
||
The supported deployment shapes all use admin tokens: addon's
|
||
SUPERVISOR_TOKEN maps to HA's `hassio_user`, which HA force-promotes
|
||
into `GROUP_ID_ADMIN` (hassio/__init__.py); standalone Docker/pip
|
||
deployments use a user-supplied admin LLAT.
|
||
"""
|
||
if not call.context.user_id:
|
||
return True
|
||
user = await hass.auth.async_get_user(call.context.user_id)
|
||
return user is not None and bool(user.is_admin)
|
||
|
||
|
||
def _is_within_config_dir(config_dir: Path, normalized: str) -> bool:
|
||
"""Resolve ``config_dir / normalized`` and confirm it stays within
|
||
``config_dir`` (symlink-aware).
|
||
|
||
Uses ``Path.is_relative_to`` rather than a string prefix so a sibling like
|
||
``<config>-evil`` can't masquerade as being inside the config dir. Fails
|
||
closed on any resolution error.
|
||
"""
|
||
try:
|
||
resolved = (config_dir / normalized).resolve()
|
||
config_resolved = config_dir.resolve()
|
||
except (OSError, ValueError):
|
||
return False
|
||
return resolved == config_resolved or resolved.is_relative_to(config_resolved)
|
||
|
||
|
||
def _violates_deny_floor(config_dir: Path, normalized: str) -> bool:
|
||
"""True if ``normalized`` (a ``normpath``'d, config-relative path) hits the
|
||
non-overridable deny floor (issue #1567).
|
||
|
||
Checked BEFORE any allow decision on every read/write/list/delete, so a
|
||
user-configured extra directory — whether stored or supplied in-flight —
|
||
can never reach these locations. See ``const.DENY_PATH_SEGMENTS`` /
|
||
``const.DENY_READ_BASENAMES`` for the rationale.
|
||
"""
|
||
# All comparisons are case-insensitive: on a case-insensitive filesystem
|
||
# (macOS APFS, some Docker bind mounts / SMB) ".STORAGE" opens the real
|
||
# ".storage", so an exact-case match would let a mixed-case entry slip
|
||
# through. The deny sets are already lowercase, so lowering the input is
|
||
# enough. This only ever denies MORE — no legitimate path is a case-variant
|
||
# of ".storage"/"secrets.yaml".
|
||
parts = [p for p in normalized.split(os.sep) if p]
|
||
# A .storage segment anywhere in the relative path (".storage",
|
||
# ".storage/auth", "x/.storage/y").
|
||
if any(p.lower() in DENY_PATH_SEGMENTS for p in parts):
|
||
return True
|
||
# Symlink defence: resolve and reject if the real target passes THROUGH a
|
||
# denied segment (e.g. an in-config symlink pointing at .storage). Scan ONLY
|
||
# the portion of the resolved path that is *under the config dir* — not the
|
||
# full absolute path — so the floor doesn't blanket-ban every access when the
|
||
# config dir itself happens to live below a ".storage" component (e.g.
|
||
# ``/var/.storage/config``). This mirrors the pre-PR allowlist model, which
|
||
# likewise only ever reasons about the config-relative path. ``relative_to``
|
||
# raising ValueError means the resolved target escaped the config dir (e.g. a
|
||
# symlink out) — fail closed. Fail closed on any resolution error too.
|
||
try:
|
||
resolved = (config_dir / normalized).resolve()
|
||
rel_parts = resolved.relative_to(config_dir.resolve()).parts
|
||
except (OSError, ValueError):
|
||
return True
|
||
if any(seg.lower() in DENY_PATH_SEGMENTS for seg in rel_parts):
|
||
return True
|
||
# secrets.yaml — by basename of BOTH the requested path AND the resolved
|
||
# target, so a renamed symlink (``www/notes.txt`` → ``secrets.yaml``) can't
|
||
# dodge it and then escape masking (the read handler masks only the literal
|
||
# ``secrets.yaml``). The canonical config-root file is the one exception,
|
||
# matched EXACTLY (not lowercased) because that is the only path the handler
|
||
# masks — a mixed-case ``SECRETS.YAML`` at the root is NOT masked, so it
|
||
# must be denied.
|
||
return (
|
||
os.path.basename(normalized).lower() in DENY_READ_BASENAMES
|
||
or resolved.name.lower() in DENY_READ_BASENAMES
|
||
) and normalized != "secrets.yaml"
|
||
|
||
|
||
def _volume_root_for(abs_path: str) -> str | None:
|
||
"""Return the HAOS sibling-volume root (``const.ALLOWED_VOLUME_ROOTS``) that
|
||
``abs_path`` falls within, or ``None`` (issue #1586).
|
||
|
||
Boundary-aware on a path-separator: ``/share`` and ``/share/x`` match
|
||
``/share``; ``/shared`` and ``/backups`` do NOT (a string-prefix match would
|
||
wrongly admit them). ``abs_path`` must already be ``posixpath.normpath``'d —
|
||
the volume roots are inherently POSIX and the component only runs on HA Core
|
||
(Linux), so we never use the host ``os.sep`` here.
|
||
"""
|
||
for root in ALLOWED_VOLUME_ROOTS:
|
||
if abs_path == root or abs_path.startswith(root + "/"):
|
||
return root
|
||
return None
|
||
|
||
|
||
def _resolves_within(base: Path, raw_path: str) -> bool:
|
||
"""Resolve ``raw_path`` exactly as ``open(2)`` will — following symlinks,
|
||
THEN applying ``..`` against the real target — and confirm it stays within
|
||
``base`` (symlink-safe containment; issue #1586 review).
|
||
|
||
The lexical allow checks run on ``os.path.normpath(raw_path)``, which
|
||
collapses ``..`` *textually* and so erases an intermediate symlink component
|
||
(``<allowed>/<symlink>/..``) that the kernel would actually traverse at open
|
||
time — the divergence that let a configured volume reach arbitrary files.
|
||
Resolving the RAW input (not the normpath-collapsed form) closes it, because
|
||
the handlers open ``config_dir / raw_path`` and the OS resolves it the same
|
||
way ``Path.resolve()`` does. Fails closed on any resolution error.
|
||
"""
|
||
try:
|
||
candidate = Path(raw_path) if raw_path.startswith("/") else base / raw_path
|
||
real = candidate.resolve()
|
||
base_real = base.resolve()
|
||
except (OSError, ValueError):
|
||
return False
|
||
return real == base_real or real.is_relative_to(base_real)
|
||
|
||
|
||
def _violates_volume_deny_floor(abs_path: str) -> bool:
|
||
"""True if an absolute HAOS volume path hits the non-overridable deny floor
|
||
(issue #1586).
|
||
|
||
The same floor as the config dir — a ``.storage`` segment anywhere, or a
|
||
``secrets.yaml`` basename — applied to both the requested path and its
|
||
symlink-resolved target, case-insensitively. Unlike the config dir there is
|
||
NO canonical ``secrets.yaml`` exception: volume reads are never masked, so a
|
||
``secrets.yaml`` on any volume is always denied. Fails closed on any
|
||
resolution error.
|
||
"""
|
||
req = PurePosixPath(abs_path)
|
||
if any(seg.lower() in DENY_PATH_SEGMENTS for seg in req.parts):
|
||
return True
|
||
if req.name.lower() in DENY_READ_BASENAMES:
|
||
return True
|
||
try:
|
||
resolved = Path(abs_path).resolve()
|
||
except (OSError, ValueError):
|
||
return True
|
||
if any(seg.lower() in DENY_PATH_SEGMENTS for seg in resolved.parts):
|
||
return True
|
||
return resolved.name.lower() in DENY_READ_BASENAMES
|
||
|
||
|
||
def _normalize_volume_dir(entry: str) -> str | None:
|
||
"""Validate one absolute HAOS sibling-volume directory (issue #1586).
|
||
|
||
Returns the cleaned absolute path, or ``None`` if it is not at/under a known
|
||
volume root or hits the deny floor. POSIX-normalized regardless of host OS
|
||
(the roots are inherently POSIX and the component only runs on HA Core).
|
||
Existence is NOT required (mirrors config-relative extra dirs); an unmounted
|
||
volume just yields ``not found`` at use time.
|
||
"""
|
||
normalized = posixpath.normpath(entry)
|
||
root = _volume_root_for(normalized)
|
||
if root is None:
|
||
return None
|
||
if _violates_volume_deny_floor(normalized):
|
||
return None
|
||
return normalized
|
||
|
||
|
||
def _is_volume_path_allowed(abs_path: str, extra_dirs: list[str] | None) -> bool:
|
||
"""Enforce a read/write/list/delete against an absolute HAOS volume path
|
||
(issue #1586).
|
||
|
||
Allowed iff the path is at/under a configured extra dir that is itself a
|
||
volume path, clears the deny floor, and — after FULL symlink + ``..``
|
||
resolution of the RAW path (matching ``open(2)``) — stays within its volume
|
||
root. Resolving the raw path rather than the lexically ``normpath``'d form
|
||
closes the ``<volume>/<symlink>/..`` escape (issue #1586 review). Read and
|
||
write share this gate — a configured volume grants read+write (#1567).
|
||
"""
|
||
normalized = posixpath.normpath(abs_path)
|
||
root = _volume_root_for(normalized)
|
||
if root is None:
|
||
return False
|
||
if _violates_volume_deny_floor(normalized):
|
||
return False
|
||
# POSIX-explicit allowlist match (not _matches_extra_dir, which joins on
|
||
# os.sep): a volume path is inherently "/"-separated, so match on a "/"
|
||
# boundary so a configured "/share" grants "/share" and "/share/..." but
|
||
# never "/shared". Config-relative entries in the mixed list never match an
|
||
# absolute path here.
|
||
if not extra_dirs or not any(
|
||
normalized == d or normalized.startswith(d + "/") for d in extra_dirs
|
||
):
|
||
return False
|
||
return _resolves_within(Path(root), abs_path)
|
||
|
||
|
||
def _normalize_extra_dir(entry: str, config_dir: Path) -> str | None:
|
||
"""Validate and normalize one user-supplied extra directory (issues #1567,
|
||
#1586).
|
||
|
||
Returns the cleaned directory, or ``None`` if the entry must be rejected.
|
||
Two accepted shapes:
|
||
|
||
* A config-relative directory (issue #1567) — rejected if empty, the config
|
||
root, uses ``..`` traversal, resolves outside the config dir, or hits the
|
||
deny floor.
|
||
* An absolute HAOS sibling-volume path (issue #1586) — kept absolute and
|
||
validated against its volume root (``const.ALLOWED_VOLUME_ROOTS``) instead
|
||
of the config dir. Any other absolute path is still rejected.
|
||
|
||
Rejected entries are dropped (mirrors the filter-to-known-good discipline
|
||
used for ``disabled_packages_keys``).
|
||
"""
|
||
if not isinstance(entry, str):
|
||
return None
|
||
stripped = entry.strip()
|
||
if not stripped:
|
||
return None
|
||
# Absolute HAOS sibling-volume path (issue #1586) — validated against its
|
||
# volume root, not the config dir. Detected by a POSIX-absolute leading "/"
|
||
# (not os.path.isabs, which is False for "/share" on a non-POSIX host) so the
|
||
# logic is identical on every OS the tests run on. Any non-volume absolute
|
||
# path is rejected inside _normalize_volume_dir.
|
||
if stripped.startswith("/"):
|
||
return _normalize_volume_dir(stripped)
|
||
cleaned = stripped.strip("/")
|
||
if not cleaned:
|
||
return None
|
||
normalized = os.path.normpath(cleaned)
|
||
if (
|
||
normalized in (".", "")
|
||
or normalized.startswith("..")
|
||
or normalized.startswith("/")
|
||
):
|
||
return None
|
||
if not _is_within_config_dir(config_dir, normalized):
|
||
return None
|
||
if _violates_deny_floor(config_dir, normalized):
|
||
return None
|
||
return normalized
|
||
|
||
|
||
def _matches_extra_dir(normalized: str, extra_dirs: list[str] | None) -> bool:
|
||
"""True if ``normalized`` IS one of the user-configured extra directories
|
||
or a path under one (issue #1567).
|
||
|
||
Prefix match on a path-separator boundary so a multi-segment entry like
|
||
``foo/bar`` grants ``foo/bar`` and ``foo/bar/...`` exactly as configured
|
||
(the built-in allowlists are single-segment and matched on ``parts[0]``,
|
||
but extra dirs may be nested), while ``foo`` never matches ``foobar``.
|
||
"""
|
||
if not extra_dirs:
|
||
return False
|
||
return any(normalized == d or normalized.startswith(d + os.sep) for d in extra_dirs)
|
||
|
||
|
||
class _PackagesDir(str):
|
||
"""Marker for the folder argument of a packages ``!include_dir_*named``."""
|
||
|
||
|
||
def _capture_packages_dir(loader: yaml.Loader, node: yaml.nodes.Node) -> _PackagesDir:
|
||
"""Construct a packages include-dir tag as its folder-name argument."""
|
||
return _PackagesDir(str(node.value))
|
||
|
||
|
||
def _follow_include(loader: yaml.Loader, node: yaml.nodes.Node) -> Any:
|
||
"""Follow ``!include`` so a split ``homeassistant:`` / ``packages:`` block in
|
||
another file is still seen during folder discovery (#1854 review).
|
||
|
||
Depth-guarded and best-effort: an unreadable/unparseable target (or a
|
||
string-stream loader with no base path) yields ``None``, like any other tag.
|
||
Only the referenced file's structure is read; every other HA tag inside it
|
||
is still dropped by the ignore constructor.
|
||
"""
|
||
depth = getattr(loader, "_pkg_include_depth", 0)
|
||
name = getattr(loader, "name", "")
|
||
if depth >= 8 or not isinstance(name, str) or not name or name.startswith("<"):
|
||
return None
|
||
path = os.path.join(os.path.dirname(name), str(node.value))
|
||
# Recorded BEFORE the open, and even when it fails: the caching layer keys
|
||
# on these files' mtimes, so an include target that does not exist yet must
|
||
# still be tracked (with a -1 stamp) or creating it later would not
|
||
# invalidate.
|
||
opened = getattr(loader, "_pkg_opened", None)
|
||
if opened is not None:
|
||
opened.append(path)
|
||
try:
|
||
with open(path, encoding="utf-8") as handle:
|
||
sub = _PackagesDirLoader(handle)
|
||
sub._pkg_include_depth = depth + 1
|
||
# Same list object, so a nested include lands in one signature.
|
||
sub._pkg_opened = opened
|
||
try:
|
||
return sub.get_single_data()
|
||
finally:
|
||
sub.dispose()
|
||
except (OSError, yaml.YAMLError):
|
||
return None
|
||
|
||
|
||
def _ignore_unknown_tag(
|
||
loader: yaml.Loader, tag_suffix: str, node: yaml.nodes.Node
|
||
) -> None:
|
||
"""Drop any other HA custom tag (``!secret`` / ``!env_var`` / ...).
|
||
|
||
Folder discovery only needs the ``homeassistant: packages:`` structure, so
|
||
unresolved tags are irrelevant — turning them into ``None`` lets
|
||
configuration.yaml parse structurally without resolving secrets, which is
|
||
what keeps this from being fragile hand-parsing.
|
||
"""
|
||
return None
|
||
|
||
|
||
class _PackagesDirLoader(yaml.SafeLoader):
|
||
"""SafeLoader that captures packages include-dir folder names, follows
|
||
``!include``, and ignores every other HA custom tag."""
|
||
|
||
|
||
_PackagesDirLoader.add_constructor("!include_dir_named", _capture_packages_dir)
|
||
_PackagesDirLoader.add_constructor("!include_dir_merge_named", _capture_packages_dir)
|
||
_PackagesDirLoader.add_constructor("!include", _follow_include)
|
||
_PackagesDirLoader.add_multi_constructor("!", _ignore_unknown_tag)
|
||
|
||
|
||
def _extract_package_dir_markers(data: object) -> set[str]:
|
||
"""Return the raw folder argument(s) of the packages include directive(s) in
|
||
a parsed configuration mapping. Inline packages declare no folder."""
|
||
if not isinstance(data, dict):
|
||
return set()
|
||
core = data.get("homeassistant")
|
||
if not isinstance(core, dict):
|
||
return set()
|
||
packages = core.get("packages")
|
||
markers: list[_PackagesDir] = []
|
||
if isinstance(packages, _PackagesDir):
|
||
markers.append(packages)
|
||
elif isinstance(packages, dict):
|
||
markers.extend(v for v in packages.values() if isinstance(v, _PackagesDir))
|
||
return {str(m) for m in markers}
|
||
|
||
|
||
def _load_package_dir_markers_tracked(config_path: str) -> tuple[set[str], list[str]]:
|
||
"""``_load_package_dir_markers`` that also reports every file it read.
|
||
|
||
The file list (configuration.yaml plus any ``!include`` followed from it) is
|
||
what ``_package_dir_markers_cached`` keys its mtime signature on, so the
|
||
cache invalidates no matter which of them the packages directive lives in.
|
||
"""
|
||
opened: list[str] = [config_path]
|
||
try:
|
||
with open(config_path, encoding="utf-8") as handle:
|
||
loader = _PackagesDirLoader(handle)
|
||
loader._pkg_opened = opened
|
||
try:
|
||
data = loader.get_single_data()
|
||
finally:
|
||
loader.dispose()
|
||
except (OSError, yaml.YAMLError):
|
||
return set(), opened
|
||
return _extract_package_dir_markers(data), opened
|
||
|
||
|
||
def _load_package_dir_markers(config_path: str) -> set[str]:
|
||
"""Parse configuration.yaml (following ``!include``) and return the raw
|
||
folder argument(s) of every packages ``!include_dir_*named`` directive.
|
||
|
||
Blocking (opens files) — call via an executor. Returns raw strings, possibly
|
||
absolute; the caller relativizes and filters them against the config dir.
|
||
"""
|
||
markers, _opened = _load_package_dir_markers_tracked(config_path)
|
||
return markers
|
||
|
||
|
||
def _mtime_sig(paths: list[str]) -> tuple[tuple[str, int], ...]:
|
||
"""Stamp each path with its mtime, or -1 when it does not exist.
|
||
|
||
The -1 is load-bearing: an ``!include`` target that is missing today and
|
||
created tomorrow changes the signature, so the cache invalidates instead of
|
||
serving folder-detection that predates the file.
|
||
"""
|
||
sig: list[tuple[str, int]] = []
|
||
for path in paths:
|
||
try:
|
||
sig.append((path, os.stat(path).st_mtime_ns))
|
||
except OSError:
|
||
sig.append((path, -1))
|
||
return tuple(sig)
|
||
|
||
|
||
# config_path -> (signature of the files last read, markers found in them).
|
||
# Module-level: the folder binding is per config dir, and HA runs one per
|
||
# process. Concurrent first-callers may each miss and parse once before the
|
||
# entry lands — bounded, self-healing, and cheaper than holding a lock across
|
||
# executor threads.
|
||
_PACKAGE_DIR_CACHE: dict[str, tuple[tuple[tuple[str, int], ...], set[str]]] = {}
|
||
|
||
|
||
def _package_dir_markers_cached(config_path: str) -> set[str]:
|
||
"""``_load_package_dir_markers``, skipping the parse when nothing changed.
|
||
|
||
Blocking (stats files) — call via an executor. Every file operation resolves
|
||
the packages folder, and a ``ha_config_get_yaml`` glob fires one detection
|
||
per matched file, so this would otherwise re-parse configuration.yaml (and
|
||
whatever it includes) N times per search. Stat-per-file is cheap; the YAML
|
||
parse is not.
|
||
"""
|
||
cached = _PACKAGE_DIR_CACHE.get(config_path)
|
||
if cached is not None:
|
||
signature, markers = cached
|
||
if _mtime_sig([path for path, _ in signature]) == signature:
|
||
return set(markers)
|
||
markers, opened = _load_package_dir_markers_tracked(config_path)
|
||
# Copy in and out: the cached set must not be mutable through a caller.
|
||
_PACKAGE_DIR_CACHE[config_path] = (_mtime_sig(opened), set(markers))
|
||
return markers
|
||
|
||
|
||
def _package_folder_relative_to_config(raw: str, config_dir: str) -> str | None:
|
||
"""Normalize a captured packages-folder argument to a config-relative folder
|
||
name, or ``None`` if it escapes the config dir.
|
||
|
||
An absolute include under the config dir (``!include_dir_named
|
||
/config/integrations``) is expressed relative to it; an absolute path
|
||
elsewhere, a parent escape, or the config root itself is dropped.
|
||
"""
|
||
if raw.startswith("/"):
|
||
norm = os.path.normpath(raw)
|
||
if norm == config_dir or norm.startswith(config_dir + os.sep):
|
||
rel = os.path.relpath(norm, config_dir)
|
||
return rel if rel != "." else None
|
||
return None
|
||
folder = os.path.normpath(raw)
|
||
if folder and folder != "." and not folder.startswith(".."):
|
||
return folder
|
||
return None
|
||
|
||
|
||
async def _detect_package_dirs(hass: HomeAssistant) -> set[str]:
|
||
"""Return the config-relative folder name(s) HA loads packages from.
|
||
|
||
Home Assistant binds packages via ``homeassistant: packages:
|
||
!include_dir_named <folder>`` (or ``!include_dir_merge_named``), where
|
||
``<folder>`` is any user-chosen directory — not necessarily ``packages``
|
||
(issue #1854). The folder is read straight from that directive in
|
||
configuration.yaml (following ``!include`` for a split ``homeassistant:``
|
||
section), so it is found even when the folder is still empty (the first
|
||
package is about to be created), a nested layout resolves to the configured
|
||
include root, and an in-config absolute include is relativized.
|
||
|
||
Always includes the built-in ``"packages"`` default, and degrades to just
|
||
that if configuration.yaml can't be read.
|
||
"""
|
||
dirs = {"packages"}
|
||
config_path = hass.config.path(ALLOWED_YAML_CONFIG_FILES[0])
|
||
# normpath is a pure string transform (no I/O), so ASYNC240 doesn't apply.
|
||
config_dir = os.path.normpath(hass.config.config_dir) # noqa: ASYNC240
|
||
markers = await hass.async_add_executor_job(
|
||
_package_dir_markers_cached, config_path
|
||
)
|
||
for raw in markers:
|
||
folder = _package_folder_relative_to_config(raw, config_dir)
|
||
if folder:
|
||
dirs.add(folder)
|
||
return dirs
|
||
|
||
|
||
def _path_in_package_dir(normalized: str, package_dirs: set[str] | None) -> bool:
|
||
"""True if ``normalized`` is a ``*.yaml`` file at any depth under one of the
|
||
configured package folders.
|
||
|
||
Literal folder match (not ``fnmatch``), so a folder name containing glob
|
||
metacharacters — valid on HA's Linux filesystem — is matched as the literal
|
||
include root rather than a wildcard (#1854 review).
|
||
"""
|
||
if not normalized.endswith(".yaml"):
|
||
return False
|
||
return any(
|
||
normalized.startswith(folder + "/") for folder in (package_dirs or {"packages"})
|
||
)
|
||
|
||
|
||
def _dir_in_package_dir(normalized: str, package_dirs: set[str] | None) -> bool:
|
||
"""True if ``normalized`` IS a configured package folder, or a folder under one.
|
||
|
||
The file-level twin is ``_path_in_package_dir``; this one matches the
|
||
FOLDER itself (and nested folders) so ``list_files`` can enumerate a
|
||
packages directory the way ``read_file`` can already read the ``*.yaml``
|
||
inside it (issue #1854).
|
||
|
||
Unlike ``_path_in_package_dir``, this does NOT default to ``{"packages"}``
|
||
when ``package_dirs`` is None: ``_is_path_allowed_for_dir`` is shared with
|
||
write_file and delete_file, which must never gain package access
|
||
(``edit_yaml_config`` is the only write path to config YAML). Only the
|
||
read-side lister passes ``package_dirs``.
|
||
"""
|
||
if not package_dirs:
|
||
return False
|
||
return any(
|
||
normalized == folder or normalized.startswith(folder + "/")
|
||
for folder in package_dirs
|
||
)
|
||
|
||
|
||
def _is_path_allowed_for_dir(
|
||
config_dir: Path,
|
||
rel_path: str,
|
||
allowed_dirs: list[str],
|
||
extra_dirs: list[str] | None = None,
|
||
package_dirs: set[str] | None = None,
|
||
) -> bool:
|
||
"""Check if a path is within allowed directories.
|
||
|
||
``extra_dirs`` are the user-configured custom directories (issue #1567),
|
||
granted in addition to ``allowed_dirs``. The non-overridable deny floor is
|
||
checked first, so a custom directory can never grant access to ``.storage``
|
||
or other floored paths.
|
||
|
||
``package_dirs`` (issue #1854) widens ONLY the allow decision — every
|
||
containment, deny-floor and symlink check below still runs — and is passed
|
||
by the read-side lister alone; write and delete leave it None so a
|
||
packages folder stays non-writable through this helper.
|
||
"""
|
||
# Absolute HAOS sibling-volume path (issue #1586) — enforced against its
|
||
# volume root rather than the config dir. Detected by a POSIX-absolute
|
||
# leading "/" and passed RAW (not normpath'd) so symlink resolution matches
|
||
# what the handler's open() does. Any non-volume absolute path is rejected
|
||
# inside the helper.
|
||
if rel_path.startswith("/"):
|
||
return _is_volume_path_allowed(rel_path, extra_dirs)
|
||
|
||
# Normalize the path
|
||
normalized = os.path.normpath(rel_path)
|
||
|
||
# Check for path traversal attempts
|
||
if normalized.startswith("..") or normalized.startswith("/"):
|
||
return False
|
||
|
||
# NON-OVERRIDABLE deny floor — before any allow decision (issue #1567).
|
||
if _violates_deny_floor(config_dir, normalized):
|
||
return False
|
||
|
||
# Built-in allowlist matches on the first segment; user-configured extra
|
||
# dirs match on a path-boundary prefix (so nested entries work). A
|
||
# configured packages folder matches literally (it may itself be nested,
|
||
# e.g. "conf/packages", so a first-segment test would miss it).
|
||
parts = normalized.split(os.sep)
|
||
builtin_ok = bool(parts) and parts[0] in allowed_dirs
|
||
if (
|
||
not builtin_ok
|
||
and not _dir_in_package_dir(normalized, package_dirs)
|
||
and not _matches_extra_dir(normalized, extra_dirs)
|
||
):
|
||
return False
|
||
|
||
# Symlink-safe containment on the REAL path the handler will open (issue
|
||
# #1586 review): resolve the RAW rel_path — open(2) follows symlinks then
|
||
# applies "..", which the lexical normpath above cannot model — and confirm
|
||
# it stays under the config dir. Closes the `<allowed>/<symlink>/../escape`
|
||
# traversal the lexical checks miss.
|
||
if not _resolves_within(config_dir, rel_path):
|
||
return False
|
||
|
||
# Resolve full path and verify it's still under config_dir
|
||
return _is_within_config_dir(config_dir, normalized)
|
||
|
||
|
||
def _is_path_allowed_for_read(
|
||
config_dir: Path,
|
||
rel_path: str,
|
||
extra_dirs: list[str] | None = None,
|
||
package_dirs: set[str] | None = None,
|
||
) -> bool:
|
||
"""Check if a path is allowed for reading.
|
||
|
||
Allowed:
|
||
- Files directly in config dir: configuration.yaml, automations.yaml, etc.
|
||
- 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)
|
||
|
||
``package_dirs`` is the set of configured packages folder name(s) (issue
|
||
#1854); it defaults to ``{"packages"}`` when not supplied so callers that
|
||
don't resolve the live config keep the historical behaviour.
|
||
|
||
The non-overridable deny floor is checked first, so a custom directory can
|
||
never reach ``.storage`` or an unmasked ``secrets.yaml``.
|
||
"""
|
||
# Absolute HAOS sibling-volume path (issue #1586) — enforced against its
|
||
# volume root rather than the config dir. Detected by a POSIX-absolute
|
||
# leading "/" and passed RAW so symlink resolution matches the handler's
|
||
# open(). A configured volume grants read too, so reads route through the
|
||
# same gate as dir/write.
|
||
if rel_path.startswith("/"):
|
||
return _is_volume_path_allowed(rel_path, extra_dirs)
|
||
|
||
normalized = os.path.normpath(rel_path)
|
||
|
||
# Check for path traversal attempts
|
||
if normalized.startswith("..") or normalized.startswith("/"):
|
||
return False
|
||
|
||
# Resolve full path and verify it's still under config_dir
|
||
if not _is_within_config_dir(config_dir, normalized):
|
||
return False
|
||
|
||
# NON-OVERRIDABLE deny floor — before any allow decision (issue #1567).
|
||
if _violates_deny_floor(config_dir, normalized):
|
||
return False
|
||
|
||
# Symlink-safe containment on the REAL path the handler will open (issue
|
||
# #1586 review): resolve the RAW rel_path so a `<allowed>/<symlink>/..` that
|
||
# lexically stays inside but physically escapes is rejected.
|
||
if not _resolves_within(config_dir, rel_path):
|
||
return False
|
||
|
||
# Check if it's one of the explicitly allowed files in config root
|
||
if normalized in ALLOWED_READ_FILES:
|
||
return True
|
||
|
||
# Check if path starts with an allowed directory
|
||
parts = normalized.split(os.sep)
|
||
if parts and parts[0] in ALLOWED_READ_DIRS:
|
||
return True
|
||
|
||
# Check for <packages-folder>/*.yaml at any depth. The configured folder(s)
|
||
# default to {"packages"} (issue #1854); literal match so a folder name with
|
||
# glob metacharacters is treated as the include root, not a wildcard.
|
||
if _path_in_package_dir(normalized, package_dirs):
|
||
return True
|
||
|
||
# Check for custom_components/**/*.py pattern
|
||
if fnmatch.fnmatch(normalized, "custom_components/**/*.py"):
|
||
return True
|
||
|
||
# User-configured extra directories (read+write) — issue #1567. Prefix
|
||
# match so nested entries (e.g. "foo/bar") grant paths under them.
|
||
return _matches_extra_dir(normalized, extra_dirs)
|
||
|
||
|
||
def _mask_secrets_content(content: str) -> str:
|
||
"""Return secrets.yaml content with every secret value masked.
|
||
|
||
Parses the document structurally (ruamel — the same YAML stack used
|
||
elsewhere in this component) and emits ``key: "[MASKED]"`` for each
|
||
top-level key. This closes the gap in the previous line-by-line regex, which
|
||
masked only single-line ``key: value`` pairs and leaked multi-line block
|
||
scalars (``|``, ``>``) whose continuation lines have no colon — SSH keys,
|
||
TLS material, and service-account JSON are commonly stored that way.
|
||
|
||
The marker is quoted because the masked text is itself valid YAML that gets
|
||
re-parsed: read_file's ``yaml_path``/``include_parsed`` views load it, and
|
||
an unquoted ``[MASKED]`` is flow-sequence syntax, so the parsed view would
|
||
render the mask as the list ``["MASKED"]`` instead of a scalar.
|
||
|
||
Fails closed: any content that cannot be parsed and masked as a key-value
|
||
mapping is withheld rather than returned raw, so a failure on this path never
|
||
leaks secrets. The catch is deliberately broad — masking is a security
|
||
boundary, so every failure mode (parse error, a pathological tag constructor,
|
||
a YAML init/threading error) must withhold, not propagate to the caller with
|
||
the raw content still in scope.
|
||
"""
|
||
try:
|
||
parsed = make_yaml().load(content)
|
||
if not isinstance(parsed, dict):
|
||
# Empty file (None) or a top-level list/scalar: no top-level keys to
|
||
# mask, so withhold rather than risk returning unmasked content.
|
||
return (
|
||
"# secrets.yaml is empty or not a key-value mapping — content withheld"
|
||
)
|
||
return "\n".join(f'{key}: "[MASKED]"' for key in parsed)
|
||
except YAMLError:
|
||
return "# secrets.yaml could not be parsed — content withheld to avoid leaking secrets"
|
||
except Exception:
|
||
return "# secrets.yaml could not be masked — content withheld to avoid leaking secrets"
|
||
|
||
|
||
def _validate_dashboard_filename(filename: str) -> str | None:
|
||
"""Validate a YAML-mode dashboard `filename:` value.
|
||
|
||
Returns None if valid, otherwise a human-readable error string.
|
||
|
||
Rules:
|
||
- Must be a non-empty string.
|
||
- Must end in '.yaml'.
|
||
- Must resolve to a path under 'dashboards/' (no traversal escape).
|
||
- No absolute paths, no '..' segments.
|
||
"""
|
||
if not filename or not isinstance(filename, str):
|
||
return "filename must be a non-empty string"
|
||
if filename.startswith("/"):
|
||
return "filename must not be an absolute path"
|
||
if not filename.endswith(".yaml"):
|
||
return "filename must end with .yaml"
|
||
|
||
normalized = os.path.normpath(filename)
|
||
if normalized.startswith("..") or normalized.startswith("/"):
|
||
return "filename must not escape the config directory"
|
||
|
||
parts = normalized.split(os.sep)
|
||
if not parts or parts[0] != "dashboards":
|
||
return "filename must be under dashboards/"
|
||
# Reject any '..' segment (defence-in-depth after normpath collapse)
|
||
if ".." in parts:
|
||
return "filename must not contain path traversal segments"
|
||
return None
|
||
|
||
|
||
def _list_files_sync(
|
||
target_dir: Path, config_dir: Path, pattern: str | None
|
||
) -> dict[str, Any]:
|
||
"""Bundle list_files blocking I/O for a single executor offload.
|
||
|
||
Returns either {"files": [...]} or {"_error": <kind>} so the async caller
|
||
can format the structured error response without re-entering the executor.
|
||
"""
|
||
if not target_dir.exists():
|
||
return {"_error": "not_found"}
|
||
if not target_dir.is_dir():
|
||
return {"_error": "not_a_dir"}
|
||
files: list[dict[str, Any]] = []
|
||
for item in target_dir.iterdir():
|
||
if pattern and not fnmatch.fnmatch(item.name, pattern):
|
||
continue
|
||
stat = item.stat()
|
||
# Config-relative paths report relative to the config dir; absolute
|
||
# HAOS sibling-volume paths (issue #1586) are not under it, so report
|
||
# them absolute (the caller passes absolute paths for volumes too).
|
||
try:
|
||
reported_path = str(item.relative_to(config_dir))
|
||
except ValueError:
|
||
reported_path = str(item)
|
||
files.append(
|
||
{
|
||
"name": item.name,
|
||
"path": reported_path,
|
||
"is_dir": item.is_dir(),
|
||
"size": stat.st_size if item.is_file() else 0,
|
||
"modified": stat.st_mtime,
|
||
}
|
||
)
|
||
files.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||
return {"files": files}
|
||
|
||
|
||
def _read_file_sync(target_file: Path) -> dict[str, Any]:
|
||
"""Bundle read_file blocking I/O for a single executor offload."""
|
||
if not target_file.exists():
|
||
return {"_error": "not_found"}
|
||
if not target_file.is_file():
|
||
return {"_error": "not_a_file"}
|
||
stat = target_file.stat()
|
||
content = target_file.read_text()
|
||
return {"content": content, "size": stat.st_size, "mtime": stat.st_mtime}
|
||
|
||
|
||
def _write_file_sync(
|
||
target_file: Path,
|
||
content: str,
|
||
overwrite: bool,
|
||
create_dirs: bool,
|
||
config_dir: Path,
|
||
) -> dict[str, Any]:
|
||
"""Bundle write_file blocking I/O for a single executor offload."""
|
||
exists = target_file.exists()
|
||
if exists and not overwrite:
|
||
return {"_error": "exists_no_overwrite"}
|
||
if create_dirs:
|
||
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||
elif not target_file.parent.exists():
|
||
# Config-relative parents report relative to the config dir; an absolute
|
||
# HAOS sibling-volume parent (issue #1586) is not under it, so report it
|
||
# absolute rather than raising ValueError on relative_to.
|
||
try:
|
||
parent = str(target_file.parent.relative_to(config_dir))
|
||
except ValueError:
|
||
parent = str(target_file.parent)
|
||
return {
|
||
"_error": "no_parent",
|
||
"parent": parent,
|
||
}
|
||
target_file.write_text(content)
|
||
stat = target_file.stat()
|
||
return {"size": stat.st_size, "mtime": stat.st_mtime, "is_new": not exists}
|
||
|
||
|
||
def _delete_file_sync(target_file: Path) -> dict[str, Any]:
|
||
"""Bundle delete_file blocking I/O for a single executor offload."""
|
||
if not target_file.exists():
|
||
return {"_error": "not_found"}
|
||
if not target_file.is_file():
|
||
return {"_error": "not_a_file"}
|
||
stat = target_file.stat()
|
||
target_file.unlink()
|
||
return {"size": stat.st_size}
|
||
|
||
|
||
def _replace_file_sync(target_file: Path, content: str) -> dict[str, Any]:
|
||
"""Bundle whole-file replace I/O for a single executor offload.
|
||
|
||
mkdir parent + atomic temp-write + rename + stat, mirroring
|
||
``_write_file_sync``. Used by edit_yaml_config(action="replace_file").
|
||
"""
|
||
target_file.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_file = target_file.with_suffix(".tmp")
|
||
tmp_file.write_text(content)
|
||
os.replace(str(tmp_file), str(target_file))
|
||
stat = target_file.stat()
|
||
return {"size": stat.st_size, "mtime": stat.st_mtime}
|
||
|
||
|
||
# Pre-#1579 YAML backups live here (config-root dotdir, never under www/ —
|
||
# GHSA-g39v-cvjh-8fpf). New writes no longer land here; these are read-only
|
||
# historical artifacts surfaced through the shared edits-backup interface.
|
||
_LEGACY_BACKUP_DIRNAME = ".ha_mcp_tools_backups"
|
||
|
||
# Legacy .bak filename shape: "<safe_name>.<YYYYMMDD>_<HHMMSS>.bak", where
|
||
# safe_name = os.path.normpath(rel_path).replace(os.sep, "_") and the timestamp
|
||
# is strftime("%Y%m%d_%H%M%S") (the pre-#1579 component-side naming).
|
||
_LEGACY_BACKUP_RE = re.compile(r"^(?P<safe>.+)\.(?P<date>\d{8})_(?P<time>\d{6})\.bak$")
|
||
|
||
|
||
def _decode_legacy_backup_name(filename: str) -> dict[str, Any]:
|
||
"""Best-effort inverse of the legacy .bak naming.
|
||
|
||
Returns ``{file_path, timestamp, path_ambiguous}``. ``file_path`` is the
|
||
config-relative path the backup was taken from, or ``None`` when it can't be
|
||
recovered. The original naming replaced every ``os.sep`` with ``_``, which is
|
||
lossy: only the flat allowlisted shapes (``configuration.yaml``,
|
||
``packages/<name>.yaml``, ``themes/<name>.yaml``) invert unambiguously. A
|
||
nested path (``packages/sub/foo.yaml``), a literal underscore in the name, or
|
||
a pre-fix ``www/yaml_backups`` artifact cannot be distinguished and is
|
||
flagged ``path_ambiguous`` so a caller never restores to a guessed target.
|
||
"""
|
||
match = _LEGACY_BACKUP_RE.match(filename)
|
||
if not match:
|
||
return {"file_path": None, "timestamp": None, "path_ambiguous": True}
|
||
safe = match.group("safe")
|
||
timestamp = f"{match.group('date')}_{match.group('time')}"
|
||
if safe in ALLOWED_YAML_CONFIG_FILES:
|
||
return {"file_path": safe, "timestamp": timestamp, "path_ambiguous": False}
|
||
# Mirror the subdir write-allowlist in handle_edit_yaml_config
|
||
# (packages/*.yaml + themes/*.yaml) — keep in sync if that set grows. A
|
||
# pattern outside this list just stays path_ambiguous (still listable/
|
||
# readable), so drift degrades safely rather than mis-restoring.
|
||
for prefix in ("packages", "themes"):
|
||
if safe.startswith(f"{prefix}_"):
|
||
rest = safe.removeprefix(f"{prefix}_")
|
||
return {
|
||
"file_path": f"{prefix}/{rest}",
|
||
"timestamp": timestamp,
|
||
# A remaining "_" is either a literal char or a collapsed nested
|
||
# separator — indistinguishable, so the decode can't be trusted.
|
||
"path_ambiguous": "_" in rest,
|
||
}
|
||
return {"file_path": None, "timestamp": timestamp, "path_ambiguous": True}
|
||
|
||
|
||
def _list_legacy_backups_sync(legacy_dir: Path) -> list[dict[str, Any]]:
|
||
"""Enumerate regular ``.bak`` files in the legacy backup dir (no recursion).
|
||
|
||
Skips directories, symlinks, and non-``.bak`` strays (mirrors the GHSA
|
||
migration's filter). Newest first by mtime.
|
||
"""
|
||
if not legacy_dir.is_dir():
|
||
return []
|
||
backups: list[dict[str, Any]] = []
|
||
for item in legacy_dir.iterdir():
|
||
if not item.is_file() or item.is_symlink() or item.suffix != ".bak":
|
||
continue
|
||
stat = item.stat()
|
||
decoded = _decode_legacy_backup_name(item.name)
|
||
backups.append(
|
||
{
|
||
"filename": item.name,
|
||
"file_path": decoded["file_path"],
|
||
"path_ambiguous": decoded["path_ambiguous"],
|
||
"timestamp": decoded["timestamp"],
|
||
"size": stat.st_size,
|
||
"modified": stat.st_mtime,
|
||
}
|
||
)
|
||
backups.sort(key=lambda backup: backup["modified"], reverse=True)
|
||
return backups
|
||
|
||
|
||
def _read_legacy_backup_sync(target_file: Path) -> dict[str, Any]:
|
||
"""Read a single legacy ``.bak`` file (text) for one executor offload.
|
||
|
||
Rejects symlinks up front (defense-in-depth for this surface), then reuses
|
||
``_read_file_sync`` for the exists/is_file/stat/read.
|
||
"""
|
||
if target_file.is_symlink():
|
||
return {"_error": "not_a_file"}
|
||
return _read_file_sync(target_file)
|
||
|
||
|
||
async def _run_config_check(hass: HomeAssistant, rel_path: str) -> dict[str, Any]:
|
||
"""Validate the whole HA config after a write and return fields to merge
|
||
into the write response.
|
||
|
||
Uses ``async_check_ha_config_file``, which returns an error string when the
|
||
resulting config is invalid (and ``None`` when it is valid). The
|
||
``homeassistant.check_config`` *service* is deliberately not used: it is
|
||
registered with ``SupportsResponse.NONE`` and signals errors only by
|
||
raising, so a response-returning call to it always failed and the check
|
||
never actually ran (#1660).
|
||
|
||
Never raises: a check that cannot run surfaces as
|
||
``config_check: "unavailable"`` rather than failing an already-completed
|
||
write.
|
||
"""
|
||
try:
|
||
errors = await async_check_ha_config_file(hass)
|
||
except Exception as check_err:
|
||
_LOGGER.warning(
|
||
"Config check unavailable after editing %s: %s", rel_path, check_err
|
||
)
|
||
return {"config_check": "unavailable", "config_check_error": str(check_err)}
|
||
if errors:
|
||
_LOGGER.warning(
|
||
"Config check found errors after editing %s: %s", rel_path, errors
|
||
)
|
||
return {"config_check": "errors", "config_check_errors": errors}
|
||
return {"config_check": "ok"}
|
||
|
||
|
||
async def _classify_edit_yaml_target(
|
||
hass: HomeAssistant, rel_path: str, normalized: str
|
||
) -> tuple[bool, bool, dict[str, Any] | None]:
|
||
"""Validate the edit target path and classify it as package/theme.
|
||
|
||
Returns (is_package, is_theme, error). On rejection the bools are False and
|
||
error is the response dict; on success error is None.
|
||
"""
|
||
# Validate file path — only configuration.yaml, packages/*.yaml, and themes/*.yaml
|
||
if normalized.startswith("..") or normalized.startswith("/"):
|
||
return (
|
||
False,
|
||
False,
|
||
{
|
||
"success": False,
|
||
"error": "Path traversal is not allowed.",
|
||
},
|
||
)
|
||
|
||
is_config_yaml = normalized in ALLOWED_YAML_CONFIG_FILES
|
||
is_theme = fnmatch.fnmatch(normalized, "themes/*.yaml")
|
||
# Package files may live under any folder the user binds via
|
||
# ``homeassistant: packages: !include_dir_named <folder>`` — not just the
|
||
# default ``packages`` (issue #1854). The configured folder(s) are
|
||
# detected at runtime (parsing configuration.yaml), so only do that work
|
||
# when the target isn't already a config-root or theme file. The set
|
||
# always includes ``"packages"`` as a fallback.
|
||
package_dirs: set[str] = set()
|
||
is_package = False
|
||
if not is_config_yaml and not is_theme:
|
||
package_dirs = await _detect_package_dirs(hass)
|
||
is_package = _path_in_package_dir(normalized, package_dirs)
|
||
if not is_config_yaml and not is_package and not is_theme:
|
||
allowed_pkg = ", ".join(f"{folder}/*.yaml" for folder in sorted(package_dirs))
|
||
return (
|
||
False,
|
||
False,
|
||
{
|
||
"success": False,
|
||
"error": (
|
||
f"File '{rel_path}' is not allowed. "
|
||
f"Only {', '.join(ALLOWED_YAML_CONFIG_FILES)}, {allowed_pkg}, and themes/*.yaml are supported."
|
||
),
|
||
},
|
||
)
|
||
return is_package, is_theme, None
|
||
|
||
|
||
def _check_edit_yaml_containment(
|
||
config_dir: Path, rel_path: str, normalized: str, is_theme: bool
|
||
) -> dict[str, Any] | None:
|
||
"""Reject an edit target that escapes the config dir or is a hidden theme path."""
|
||
# Symlink-safe containment (parity with the read path, issue #1586): the
|
||
# write target is ``config_dir / normalized``, so a package/theme folder
|
||
# that is (or contains) a symlink escaping the config dir — or a path
|
||
# resolving into the deny floor (.storage / secrets.yaml) — must be
|
||
# rejected before writing, exactly as read_file already does.
|
||
if _violates_deny_floor(config_dir, normalized) or not _resolves_within(
|
||
config_dir, rel_path
|
||
):
|
||
_LOGGER.warning("Rejected YAML edit escaping the config dir: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Path '{rel_path}' resolves outside the config directory or "
|
||
"into a protected location and cannot be edited."
|
||
),
|
||
}
|
||
|
||
# ``themes/*.yaml`` matches dot-prefixed paths (fnmatch's ``*`` matches a
|
||
# leading ``.`` and spans ``/``), but HA's ``!include_dir_merge_named``
|
||
# loader skips them: ``annotatedyaml``'s ``_find_files`` filters every
|
||
# walked directory AND file basename through ``_is_file_valid`` (which
|
||
# is ``return not name.startswith(".")``), so a dot-prefixed file
|
||
# (``themes/.hidden.yaml``) OR directory (``themes/.foo/bar.yaml``) is
|
||
# pruned and the theme never loads — a phantom ``reload_performed``.
|
||
# Reject if any path segment is dot-prefixed, mirroring the loader,
|
||
# rather than only checking the basename.
|
||
if is_theme and any(seg.startswith(".") for seg in normalized.split("/")):
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Theme path '{rel_path}' has a dot-prefixed file or "
|
||
"directory segment. Home Assistant's !include_dir_merge_named "
|
||
"skips any path segment whose name starts with '.', so it "
|
||
"would never load. Use a path with no dot-prefixed segment."
|
||
),
|
||
}
|
||
return None
|
||
|
||
|
||
def _check_packages_key_gate(
|
||
call: ServiceCall, yaml_path: str, is_package: bool
|
||
) -> dict[str, Any] | None:
|
||
"""Reject a package-only yaml_path key disabled by the caller's runtime config.
|
||
|
||
Returns an error response when the top-level key of a packages/*.yaml write
|
||
is in the caller's disabled set, else None.
|
||
"""
|
||
# Caller-provided per-key opt-out for PACKAGES_ONLY_YAML_KEYS.
|
||
# Filter to the recognised set so a caller that types
|
||
# ``automatoin`` doesn't accidentally pass through; only
|
||
# actually-known keys count.
|
||
disabled_packages_keys = {
|
||
key
|
||
for key in call.data.get("disabled_packages_keys", [])
|
||
if key in PACKAGES_ONLY_YAML_KEYS
|
||
}
|
||
# Per-key gate fires only for packages/*.yaml writes. Writes to
|
||
# configuration.yaml fall through to ``_parse_and_validate_yaml_path``
|
||
# which rejects PACKAGES_ONLY_YAML_KEYS with the storage-mode-
|
||
# tools advisory regardless of this flag.
|
||
top_segment = yaml_path.split(".", 1)[0] if yaml_path else ""
|
||
if is_package and top_segment in disabled_packages_keys:
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"yaml_path key {top_segment!r} is disabled by the "
|
||
f"caller's runtime configuration. Re-enable it in the "
|
||
f"caller (the ha-mcp Server Settings → YAML config "
|
||
f"editing → 'Allow {top_segment} in packages/*.yaml' "
|
||
f"toggle), or use the storage-mode equivalent."
|
||
),
|
||
}
|
||
return None
|
||
|
||
|
||
async def _apply_replace_file(
|
||
hass: HomeAssistant,
|
||
config_dir: Path,
|
||
rel_path: str,
|
||
normalized: str,
|
||
action: str,
|
||
yaml_path: str,
|
||
content: str | None,
|
||
) -> dict[str, Any]:
|
||
"""Handle action='replace_file': validate and atomically write the whole file."""
|
||
if not content:
|
||
return {
|
||
"success": False,
|
||
"error": "'content' is required for action 'replace_file'.",
|
||
}
|
||
try:
|
||
whole = await hass.async_add_executor_job(
|
||
lambda: make_yaml().load(StringIO(content))
|
||
)
|
||
except YAMLError as err:
|
||
return {"success": False, "error": f"Invalid YAML content: {err}"}
|
||
if not isinstance(whole, dict):
|
||
return {
|
||
"success": False,
|
||
"error": "Whole-file content must be a YAML mapping at the root.",
|
||
}
|
||
target_file = config_dir / normalized
|
||
try:
|
||
# Write the backup content verbatim (faithful restore). Bundles
|
||
# mkdir + atomic temp-write+rename + stat into one offload, like
|
||
# _write_file_sync.
|
||
write_meta = await hass.async_add_executor_job(
|
||
_replace_file_sync, target_file, content
|
||
)
|
||
except PermissionError:
|
||
_LOGGER.error("Permission denied editing: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Permission denied: {rel_path}",
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error editing YAML config %s: %s", rel_path, err)
|
||
return {"success": False, "error": str(err)}
|
||
|
||
_LOGGER.info("YAML config restored whole-file: %s", rel_path)
|
||
restore_result: dict[str, Any] = {
|
||
"success": True,
|
||
"file": rel_path,
|
||
"action": action,
|
||
"yaml_path": yaml_path,
|
||
# Verbatim whole-file write; shares the write-path response
|
||
# shape so ``written`` stays a reliable discriminator.
|
||
"written": True,
|
||
"size": write_meta["size"],
|
||
"modified": datetime.fromtimestamp(write_meta["mtime"]).isoformat(),
|
||
# A whole-file restore can touch any number of keys; a restart
|
||
# is the always-correct activation path.
|
||
"post_action": "restart_required",
|
||
}
|
||
restore_result.update(await _run_config_check(hass, rel_path))
|
||
return restore_result
|
||
|
||
|
||
async def _parse_edit_content(
|
||
hass: HomeAssistant, action: str, content: str | None, kind: str
|
||
) -> tuple[Any, dict[str, Any] | None]:
|
||
"""Validate content is valid YAML for add/replace.
|
||
|
||
Returns (parsed_content, error). For other actions parsed_content is None.
|
||
"""
|
||
parsed_content: Any = None
|
||
if action in ("add", "replace"):
|
||
if not content:
|
||
return None, {
|
||
"success": False,
|
||
"error": f"'content' is required for action '{action}'.",
|
||
}
|
||
try:
|
||
parsed_content = await hass.async_add_executor_job(
|
||
lambda: make_yaml().load(StringIO(content))
|
||
)
|
||
except YAMLError as err:
|
||
return None, {
|
||
"success": False,
|
||
"error": f"Invalid YAML content: {err}",
|
||
}
|
||
if parsed_content is None:
|
||
return None, {
|
||
"success": False,
|
||
"error": "Content parsed as null/empty. Provide non-empty YAML.",
|
||
}
|
||
# Theme content must be a YAML mapping (theme variables)
|
||
if kind == "theme" and not isinstance(parsed_content, dict):
|
||
return None, {
|
||
"success": False,
|
||
"error": "Theme content must be a YAML mapping (theme variables).",
|
||
}
|
||
return parsed_content, None
|
||
|
||
|
||
async def _load_edit_target_data(
|
||
hass: HomeAssistant, target_file: Path, action: str, rel_path: str
|
||
) -> tuple[dict[str, Any], str, dict[str, Any] | None]:
|
||
"""Read + parse the existing YAML file, or start empty.
|
||
|
||
Returns (data, raw_content, error).
|
||
"""
|
||
# Read existing file content (or start with empty dict)
|
||
target_exists = await hass.async_add_executor_job(target_file.exists)
|
||
if target_exists:
|
||
raw_content = await hass.async_add_executor_job(target_file.read_text)
|
||
try:
|
||
data = await hass.async_add_executor_job(
|
||
lambda: make_yaml().load(StringIO(raw_content)) or {}
|
||
)
|
||
except YAMLError as err:
|
||
return (
|
||
{},
|
||
"",
|
||
{
|
||
"success": False,
|
||
"error": f"Cannot parse existing file '{rel_path}': {err}",
|
||
},
|
||
)
|
||
if not isinstance(data, dict):
|
||
return (
|
||
{},
|
||
"",
|
||
{
|
||
"success": False,
|
||
"error": f"File '{rel_path}' root is not a YAML mapping.",
|
||
},
|
||
)
|
||
else:
|
||
if action == "remove":
|
||
return (
|
||
{},
|
||
"",
|
||
{
|
||
"success": False,
|
||
"error": f"File does not exist: {rel_path}",
|
||
},
|
||
)
|
||
data = {}
|
||
raw_content = ""
|
||
return data, raw_content, None
|
||
|
||
|
||
def _walk_lovelace_dashboards(
|
||
data: dict[str, Any], action: str, parsed_content: Any
|
||
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]:
|
||
"""Validate dashboard content and walk/create lovelace.dashboards in ``data``.
|
||
|
||
Returns (lovelace, dashboards, error). On error the mappings are empty dicts.
|
||
"""
|
||
# filename validation for add/replace
|
||
if action in ("add", "replace"):
|
||
if not isinstance(parsed_content, dict):
|
||
return (
|
||
{},
|
||
{},
|
||
{
|
||
"success": False,
|
||
"error": "lovelace.dashboards.<url_path> content must be a YAML mapping",
|
||
},
|
||
)
|
||
fn_err = _validate_dashboard_filename(parsed_content.get("filename", ""))
|
||
if fn_err is not None:
|
||
return {}, {}, {"success": False, "error": fn_err}
|
||
|
||
# Walk/create lovelace.dashboards
|
||
lovelace = data.setdefault("lovelace", {})
|
||
if not isinstance(lovelace, dict):
|
||
return (
|
||
{},
|
||
{},
|
||
{
|
||
"success": False,
|
||
"error": "Existing 'lovelace' key is not a YAML mapping",
|
||
},
|
||
)
|
||
dashboards = lovelace.setdefault("dashboards", {})
|
||
if not isinstance(dashboards, dict):
|
||
return (
|
||
{},
|
||
{},
|
||
{
|
||
"success": False,
|
||
"error": "Existing 'lovelace.dashboards' is not a YAML mapping",
|
||
},
|
||
)
|
||
return lovelace, dashboards, None
|
||
|
||
|
||
def _apply_lovelace_dashboard_action(
|
||
data: dict[str, Any],
|
||
lovelace: dict[str, Any],
|
||
dashboards: dict[str, Any],
|
||
action: str,
|
||
url_path: str,
|
||
parsed_content: Any,
|
||
) -> dict[str, Any] | None:
|
||
"""Apply add/replace/remove for a single lovelace dashboard in ``data``."""
|
||
if action == "add":
|
||
if url_path in dashboards:
|
||
existing = dashboards[url_path]
|
||
if isinstance(existing, dict) and isinstance(parsed_content, dict):
|
||
existing.update(parsed_content)
|
||
else:
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Type mismatch for dashboard '{url_path}': use 'replace' "
|
||
"to overwrite."
|
||
),
|
||
}
|
||
else:
|
||
dashboards[url_path] = parsed_content
|
||
elif action == "replace":
|
||
dashboards[url_path] = parsed_content
|
||
elif action == "remove":
|
||
if url_path not in dashboards:
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Dashboard '{url_path}' not found under lovelace.dashboards."
|
||
),
|
||
}
|
||
del dashboards[url_path]
|
||
# Clean up empty parent containers to keep the file tidy
|
||
if not dashboards:
|
||
del lovelace["dashboards"]
|
||
if not lovelace:
|
||
del data["lovelace"]
|
||
return None
|
||
|
||
|
||
def _apply_lovelace_dashboard_edit(
|
||
data: dict[str, Any], action: str, url_path: str, parsed_content: Any
|
||
) -> dict[str, Any] | None:
|
||
"""Apply an add/replace/remove to lovelace.dashboards.<url_path> in ``data``."""
|
||
lovelace, dashboards, walk_err = _walk_lovelace_dashboards(
|
||
data, action, parsed_content
|
||
)
|
||
if walk_err is not None:
|
||
return walk_err
|
||
return _apply_lovelace_dashboard_action(
|
||
data, lovelace, dashboards, action, url_path, parsed_content
|
||
)
|
||
|
||
|
||
def _apply_single_key_edit(
|
||
data: dict[str, Any],
|
||
action: str,
|
||
yaml_key: str,
|
||
label: str,
|
||
parsed_content: Any,
|
||
rel_path: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Apply add/replace/remove for a single top-level key (or theme) in ``data``."""
|
||
# Single-key apply logic, shared by plain config keys and
|
||
# theme names (kind == "theme"): a theme file is a mapping of
|
||
# theme-name -> variables, and theme content is pre-validated
|
||
# as a mapping above, so the list-merge arm never fires for it.
|
||
if action == "add":
|
||
if yaml_key in data:
|
||
existing = data[yaml_key]
|
||
# Merge: list extends list, dict merges dict
|
||
if isinstance(existing, list) and isinstance(parsed_content, list):
|
||
data[yaml_key] = existing + parsed_content
|
||
elif isinstance(existing, dict) and isinstance(parsed_content, dict):
|
||
existing.update(parsed_content)
|
||
else:
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Type mismatch for {label.lower()} '{yaml_key}': "
|
||
f"existing is {type(existing).__name__}, "
|
||
f"new content is {type(parsed_content).__name__}. "
|
||
"Use action='replace' to overwrite."
|
||
),
|
||
}
|
||
else:
|
||
data[yaml_key] = parsed_content
|
||
elif action == "replace":
|
||
data[yaml_key] = parsed_content
|
||
elif action == "remove":
|
||
if yaml_key not in data:
|
||
return {
|
||
"success": False,
|
||
"error": f"{label} '{yaml_key}' not found in '{rel_path}'.",
|
||
}
|
||
del data[yaml_key]
|
||
return None
|
||
|
||
|
||
def _apply_edit_action(
|
||
data: dict[str, Any],
|
||
action: str,
|
||
kind: str,
|
||
path_parts: tuple[str, ...],
|
||
parsed_content: Any,
|
||
rel_path: str,
|
||
) -> dict[str, Any] | None:
|
||
"""Apply the requested edit action to ``data`` in place, returning an error or None."""
|
||
if kind == "lovelace_dashboard":
|
||
return _apply_lovelace_dashboard_edit(
|
||
data, action, path_parts[2], parsed_content
|
||
)
|
||
label = "Theme" if kind == "theme" else "Key"
|
||
return _apply_single_key_edit(
|
||
data, action, path_parts[0], label, parsed_content, rel_path
|
||
)
|
||
|
||
|
||
async def _serialize_and_validate_edit(
|
||
hass: HomeAssistant, data: dict[str, Any], raw_content: str
|
||
) -> tuple[str, dict[str, Any] | None]:
|
||
"""Serialize ``data`` to YAML and verify it round-trips unchanged.
|
||
|
||
Returns (new_content, error); new_content is "" when error is set.
|
||
"""
|
||
# Serialize back to YAML, keeping the file's dominant top-level
|
||
# sequence-dash style (#1720: "changed indentation I didn't ask
|
||
# to change"). ruamel supports only ONE sequence style per dump,
|
||
# so in a MIXED-style file the minority-style sequences are
|
||
# normalized to the first-detected style — that collateral is
|
||
# counted below and surfaced as a warning + visible in ``diff``.
|
||
seq_style = detect_seq_indent(raw_content)
|
||
|
||
def _dump() -> str:
|
||
ry = make_yaml()
|
||
apply_seq_indent(ry, seq_style)
|
||
return yaml_dumps(ry, data)
|
||
|
||
try:
|
||
new_content = await hass.async_add_executor_job(_dump)
|
||
except YAMLError as err:
|
||
return "", {
|
||
"success": False,
|
||
"error": f"Failed to serialize YAML: {err}",
|
||
}
|
||
|
||
# Validate the result parses cleanly AND round-trips to the
|
||
# same data we intend to write. The emitter must not alter any
|
||
# parsed value (e.g. by re-wrapping a long line inside a folded
|
||
# scalar, which embeds a literal newline in an untouched string
|
||
# — #1720); if it would, refuse to write rather than corrupt
|
||
# the file silently.
|
||
try:
|
||
reparsed = await hass.async_add_executor_job(
|
||
lambda: make_yaml().load(StringIO(new_content))
|
||
)
|
||
except YAMLError as err:
|
||
return "", {
|
||
"success": False,
|
||
"error": f"Generated YAML failed validation: {err}",
|
||
}
|
||
if reparsed != data:
|
||
return "", {
|
||
"success": False,
|
||
"error": (
|
||
"Aborted: re-serializing the file would have altered "
|
||
"content outside the requested edit (the YAML "
|
||
"serializer changed at least one value elsewhere in "
|
||
"the file). The file was NOT modified."
|
||
),
|
||
}
|
||
return new_content, None
|
||
|
||
|
||
def _maybe_build_confirm_preview(
|
||
call: ServiceCall,
|
||
normalized: str,
|
||
new_content: str,
|
||
rel_path: str,
|
||
action: str,
|
||
yaml_path: str,
|
||
diff_text: str,
|
||
reindent_count: int,
|
||
) -> dict[str, Any] | None:
|
||
"""Return a preview response when confirmation is required but unconfirmed."""
|
||
require_confirm = bool(call.data.get("require_confirm", False))
|
||
supplied_token = call.data.get("confirm_token")
|
||
expected_token = _confirm_token(normalized, new_content)
|
||
if not (require_confirm and supplied_token != expected_token):
|
||
return None
|
||
preview: dict[str, Any] = {
|
||
"success": True,
|
||
"preview": True,
|
||
"written": False,
|
||
"file": rel_path,
|
||
"action": action,
|
||
"yaml_path": yaml_path,
|
||
"diff": diff_text,
|
||
"confirm_token": expected_token,
|
||
"message": (
|
||
"Preview only — NOTHING was written. Review the diff "
|
||
"for unintended changes outside the requested edit, "
|
||
"then repeat the exact same call with confirm_token "
|
||
"to apply."
|
||
),
|
||
}
|
||
if reindent_count:
|
||
preview["warnings"] = [_reindent_warning(reindent_count)]
|
||
if supplied_token is not None:
|
||
preview["confirm_token_mismatch"] = True
|
||
preview["message"] = (
|
||
"confirm_token did not match — the file changed "
|
||
"since the preview (or the token was wrong). NOTHING "
|
||
"was written. Review the fresh diff and retry with "
|
||
"the new confirm_token."
|
||
)
|
||
return preview
|
||
|
||
|
||
async def _resolve_post_action(
|
||
hass: HomeAssistant, kind: str, path_parts: tuple[str, ...]
|
||
) -> dict[str, Any]:
|
||
"""Return the post-edit activation info (restart/reload) for the edit kind."""
|
||
# Surface the post-edit action required to activate the change
|
||
post_info: dict[str, Any]
|
||
if kind == "lovelace_dashboard":
|
||
post_info = {"post_action": "restart_required"}
|
||
elif kind == "theme":
|
||
# Trigger frontend.reload_themes to load the theme change
|
||
try:
|
||
await hass.services.async_call(
|
||
"frontend",
|
||
"reload_themes",
|
||
{},
|
||
blocking=True,
|
||
)
|
||
post_info = {
|
||
"post_action": "reload_performed",
|
||
"reload_service": "frontend.reload_themes",
|
||
}
|
||
except Exception as reload_err:
|
||
post_info = {
|
||
"post_action": "reload_available",
|
||
"reload_service": "frontend.reload_themes",
|
||
"reload_error": str(reload_err),
|
||
}
|
||
_LOGGER.warning(
|
||
"frontend.reload_themes failed after theme edit: %s",
|
||
reload_err,
|
||
)
|
||
else:
|
||
post_info = YAML_KEY_POST_ACTIONS.get(
|
||
path_parts[0], YAML_KEY_DEFAULT_POST_ACTION
|
||
)
|
||
return post_info
|
||
|
||
|
||
async def _apply_yaml_key_edit(
|
||
hass: HomeAssistant,
|
||
config_dir: Path,
|
||
call: ServiceCall,
|
||
rel_path: str,
|
||
normalized: str,
|
||
action: str,
|
||
yaml_path: str,
|
||
kind: str,
|
||
path_parts: tuple[str, ...],
|
||
parsed_content: Any,
|
||
) -> dict[str, Any]:
|
||
"""Read, edit, serialize, and atomically write the requested YAML key change."""
|
||
target_file = config_dir / normalized
|
||
|
||
try:
|
||
data, raw_content, load_err = await _load_edit_target_data(
|
||
hass, target_file, action, rel_path
|
||
)
|
||
if load_err is not None:
|
||
return load_err
|
||
|
||
# Pre-write backups are captured MCP-side by ha-mcp's shared
|
||
# auto-backup layer (#1579): ha_config_set_yaml snapshots the
|
||
# prior key state before calling this service, so the edit is
|
||
# restorable via ha_manage_backup(scope="edits"). The component
|
||
# no longer writes its own .ha_mcp_tools_backups/ copy. (Pre-fix
|
||
# backups already on disk there stay readable; the separate
|
||
# GHSA-g39v-cvjh-8fpf startup migration is unaffected.)
|
||
|
||
# Perform the action — branch on kind
|
||
apply_err = _apply_edit_action(
|
||
data, action, kind, path_parts, parsed_content, rel_path
|
||
)
|
||
if apply_err is not None:
|
||
return apply_err
|
||
|
||
new_content, ser_err = await _serialize_and_validate_edit(
|
||
hass, data, raw_content
|
||
)
|
||
if ser_err is not None:
|
||
return ser_err
|
||
|
||
diff_text = _unified_diff(raw_content, new_content, rel_path)
|
||
reindent_count = _count_reindented_lines(diff_text)
|
||
|
||
preview = _maybe_build_confirm_preview(
|
||
call,
|
||
normalized,
|
||
new_content,
|
||
rel_path,
|
||
action,
|
||
yaml_path,
|
||
diff_text,
|
||
reindent_count,
|
||
)
|
||
if preview is not None:
|
||
return preview
|
||
|
||
# Create parent directories if needed (for new package files).
|
||
# mkdir(exist_ok=True) is idempotent so no pre-check is required.
|
||
await hass.async_add_executor_job(
|
||
lambda: target_file.parent.mkdir(parents=True, exist_ok=True)
|
||
)
|
||
|
||
# Atomic write: write to temp file, then rename into place
|
||
def _atomic_write() -> None:
|
||
tmp_file = target_file.with_suffix(".tmp")
|
||
tmp_file.write_text(new_content)
|
||
os.replace(str(tmp_file), str(target_file))
|
||
|
||
await hass.async_add_executor_job(_atomic_write)
|
||
|
||
stat = await hass.async_add_executor_job(target_file.stat)
|
||
modified_dt = datetime.fromtimestamp(stat.st_mtime)
|
||
|
||
_LOGGER.info(
|
||
"YAML config edited: %s (action=%s, key=%s)",
|
||
rel_path,
|
||
action,
|
||
yaml_path,
|
||
)
|
||
|
||
result: dict[str, Any] = {
|
||
"success": True,
|
||
"file": rel_path,
|
||
"action": action,
|
||
"yaml_path": yaml_path,
|
||
"size": stat.st_size,
|
||
"modified": modified_dt.isoformat(),
|
||
"written": True,
|
||
"diff": diff_text,
|
||
}
|
||
if reindent_count:
|
||
result["warnings"] = [_reindent_warning(reindent_count)]
|
||
|
||
# Surface the post-edit action required to activate the change
|
||
result.update(await _resolve_post_action(hass, kind, path_parts))
|
||
result.update(await _run_config_check(hass, rel_path))
|
||
return result
|
||
|
||
except PermissionError:
|
||
_LOGGER.error("Permission denied editing: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Permission denied: {rel_path}",
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error editing YAML config %s: %s", rel_path, err)
|
||
return {
|
||
"success": False,
|
||
"error": str(err),
|
||
}
|
||
|
||
|
||
def _build_edit_yaml_config_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[dict[str, Any]]]:
|
||
"""Build and return the async handle_edit_yaml_config handler.
|
||
|
||
Extracted to module level so it can be tested without registering
|
||
the full integration.
|
||
"""
|
||
config_dir = Path(hass.config.config_dir)
|
||
|
||
async def handle_edit_yaml_config(call: ServiceCall) -> dict[str, Any]:
|
||
"""Handle the edit_yaml_config service call."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_EDIT_YAML_CONFIG)
|
||
rel_path = call.data["file"]
|
||
action = call.data["action"]
|
||
yaml_path = call.data["yaml_path"]
|
||
content = call.data.get("content")
|
||
|
||
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
||
is_package, is_theme, target_err = await _classify_edit_yaml_target(
|
||
hass, rel_path, normalized
|
||
)
|
||
if target_err is not None:
|
||
return target_err
|
||
containment_err = _check_edit_yaml_containment(
|
||
config_dir, rel_path, normalized, is_theme
|
||
)
|
||
if containment_err is not None:
|
||
return containment_err
|
||
|
||
# Whole-file replace (restore a legacy .bak wholesale, #1579). The
|
||
# content IS the entire file, so this bypasses the per-key merge — but
|
||
# reuses the path allowlist already enforced above, plus YAML
|
||
# validation, the atomic temp-write+rename, and the post-write config
|
||
# check. No new files become writable; yaml_path is ignored here.
|
||
if action == "replace_file":
|
||
return await _apply_replace_file(
|
||
hass, config_dir, rel_path, normalized, action, yaml_path, content
|
||
)
|
||
|
||
gate_err = _check_packages_key_gate(call, yaml_path, is_package)
|
||
if gate_err is not None:
|
||
return gate_err
|
||
|
||
# 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,
|
||
extra_allowed_keys=_effective_extra_allowed_keys(hass, call),
|
||
)
|
||
if path_err is not None:
|
||
return {"success": False, "error": path_err}
|
||
|
||
parsed_content, content_err = await _parse_edit_content(
|
||
hass, action, content, kind
|
||
)
|
||
if content_err is not None:
|
||
return content_err
|
||
|
||
return await _apply_yaml_key_edit(
|
||
hass,
|
||
config_dir,
|
||
call,
|
||
rel_path,
|
||
normalized,
|
||
action,
|
||
yaml_path,
|
||
kind,
|
||
path_parts,
|
||
parsed_content,
|
||
)
|
||
|
||
return handle_edit_yaml_config
|
||
|
||
|
||
def _extract_yaml_views(
|
||
content: str, yaml_path: str, include_parsed: bool = False
|
||
) -> dict[str, Any]:
|
||
"""Return the subtree at ``yaml_path`` as round-trip text and, optionally,
|
||
as JSON-safe parsed data — from a SINGLE parse of ``content``.
|
||
|
||
Runs here, in the component, because the round-trip parse needs ``ruamel``
|
||
(a component requirement) which the MCP server's runtime does not carry.
|
||
Comments and HA tags (``!secret`` / ``!include``) are preserved in the text
|
||
view and rendered to their source form in the parsed view — never resolved,
|
||
so neither view can surface a secret's plaintext.
|
||
|
||
``subtree`` is None when the root is not a mapping or the key is absent
|
||
(new-key write — nothing to snapshot). Malformed YAML also yields None, but
|
||
additionally sets ``parse_error``, so a caller can tell "this file does not
|
||
define the key" apart from "this file could not be read at all" — without
|
||
it, one broken package silently reads as a key that is simply absent.
|
||
``parsed`` is present only when ``include_parsed`` and the key resolved.
|
||
"""
|
||
views: dict[str, Any] = {"subtree": None}
|
||
try:
|
||
ry = make_yaml()
|
||
# The per-thread cached instance may carry a sequence style applied
|
||
# by a prior edit's dump on this executor thread — reset it so the
|
||
# snapshot never inherits another file's indentation.
|
||
apply_seq_indent(ry, None)
|
||
node: Any = ry.load(StringIO(content))
|
||
for seg in yaml_path.split("."):
|
||
if not isinstance(node, dict) or seg not in node:
|
||
return views
|
||
node = node[seg]
|
||
views["subtree"] = yaml_dumps(ry, node)
|
||
if include_parsed:
|
||
views["parsed"] = yaml_jsonify(node)
|
||
except YAMLError as err:
|
||
# Position only, never the error text: ruamel embeds the offending
|
||
# source line in its message, which would put file content — possibly
|
||
# an inline credential — into a response this path otherwise keeps
|
||
# free of resolved values.
|
||
mark = getattr(err, "problem_mark", None)
|
||
where = (
|
||
f" at line {mark.line + 1}, column {mark.column + 1}"
|
||
if mark is not None
|
||
else ""
|
||
)
|
||
return {"subtree": None, "parse_error": f"not valid YAML{where}"}
|
||
return views
|
||
|
||
|
||
def _extract_yaml_subtree(content: str, yaml_path: str) -> str | None:
|
||
"""Return the YAML subtree at the dotted ``yaml_path`` as round-trip text.
|
||
|
||
Used by ``read_file``'s optional ``yaml_path`` to let ha-mcp's per-edit
|
||
auto-backup snapshot the prior value of a key before ``edit_yaml_config``
|
||
changes it (#1579).
|
||
"""
|
||
subtree: str | None = _extract_yaml_views(content, yaml_path)["subtree"]
|
||
return subtree
|
||
|
||
|
||
def _validate_lovelace_dashboard_path(
|
||
yaml_path: str, parts: tuple[str, ...]
|
||
) -> tuple[str, tuple[str, ...], str | None]:
|
||
"""Validate a 'lovelace.dashboards.<url_path>' yaml_path (shape 2).
|
||
|
||
Returns (kind, parts, error); on error kind is '' and parts is ().
|
||
"""
|
||
if parts[:2] != ("lovelace", "dashboards") or len(parts) != 3:
|
||
return (
|
||
"",
|
||
(),
|
||
(
|
||
f"Dotted yaml_path '{yaml_path}' is not supported. "
|
||
"The only accepted dotted form is 'lovelace.dashboards.<url_path>'."
|
||
),
|
||
)
|
||
|
||
url_path = parts[2]
|
||
if url_path in RESERVED_DASHBOARD_URL_PATHS:
|
||
return (
|
||
"",
|
||
(),
|
||
f"url_path '{url_path}' is reserved by Home Assistant and cannot be used.",
|
||
)
|
||
if not DASHBOARD_URL_PATH_PATTERN.fullmatch(url_path):
|
||
return (
|
||
"",
|
||
(),
|
||
(
|
||
f"url_path '{url_path}' is invalid. Must be lowercase letters/digits "
|
||
"separated by hyphens (e.g., 'energy-dashboard')."
|
||
),
|
||
)
|
||
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. ``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'
|
||
|
||
Returns (kind, parts, error). On error, kind is '' and parts is ().
|
||
"""
|
||
if not yaml_path or not isinstance(yaml_path, str):
|
||
return "", (), "yaml_path must be a non-empty string"
|
||
|
||
parts = tuple(yaml_path.split("."))
|
||
|
||
# Shape 3: theme name (single segment, no dots)
|
||
if is_theme:
|
||
if len(parts) == 1:
|
||
return "theme", parts, None
|
||
return (
|
||
"",
|
||
(),
|
||
(
|
||
f"Theme name '{yaml_path}' cannot contain dots. "
|
||
"Provide a simple theme name (e.g., 'my-theme', not 'my.theme')."
|
||
),
|
||
)
|
||
|
||
# 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 (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:
|
||
return (
|
||
"",
|
||
(),
|
||
(
|
||
f"Key '{yaml_path}' is only allowed in packages/*.yaml "
|
||
"files, not in configuration.yaml. Move the edit to a "
|
||
"package file (e.g., packages/automations.yaml) or use "
|
||
"ha_config_set_automation, ha_config_set_script, or "
|
||
"ha_config_set_scene for storage-mode."
|
||
),
|
||
)
|
||
allowed = (
|
||
ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS | extra_allowed_keys
|
||
if is_package
|
||
else ALLOWED_YAML_KEYS | extra_allowed_keys
|
||
)
|
||
return (
|
||
"",
|
||
(),
|
||
(
|
||
f"Key '{yaml_path}' is not in the allowed list. "
|
||
f"Allowed keys: {', '.join(sorted(allowed))}. "
|
||
"For YAML-mode dashboards use 'lovelace.dashboards.<url_path>'."
|
||
),
|
||
)
|
||
|
||
# Shape 2: lovelace.dashboards.<url_path>
|
||
return _validate_lovelace_dashboard_path(yaml_path, parts)
|
||
|
||
|
||
def _migrate_legacy_backup_dir(config_dir: Path) -> tuple[int, int]:
|
||
"""Move pre-fix backups out of www/ (GHSA-g39v-cvjh-8fpf).
|
||
|
||
Pre-fix versions wrote backups under www/yaml_backups/, which HA serves
|
||
unauthenticated at /local/. Move .bak files into the new
|
||
.ha_mcp_tools_backups/ directory and remove the old directory if empty.
|
||
Returns (moved, failed) — counts of files successfully relocated and
|
||
files that could not be moved (left in legacy_dir, still exposed).
|
||
"""
|
||
legacy_dir = config_dir / "www" / "yaml_backups"
|
||
if not legacy_dir.is_dir():
|
||
return 0, 0
|
||
|
||
new_dir = config_dir / _LEGACY_BACKUP_DIRNAME
|
||
new_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
moved = 0
|
||
failed = 0
|
||
for src in legacy_dir.iterdir():
|
||
# Only migrate regular .bak files. Skip directories, symlinks,
|
||
# sockets/fifos, and any user-deposited stray files.
|
||
if not src.is_file() or src.is_symlink() or src.suffix != ".bak":
|
||
continue
|
||
|
||
# Pick a non-colliding destination name. If <name>.bak exists, try
|
||
# <name>.legacy.bak, then .legacy1.bak, .legacy2.bak, etc. Required
|
||
# because Path.rename / shutil.move overwrite the destination
|
||
# silently on POSIX, which would lose data.
|
||
dest = new_dir / src.name
|
||
if dest.exists():
|
||
dest = new_dir / f"{src.stem}.legacy{src.suffix}"
|
||
counter = 1
|
||
while dest.exists():
|
||
dest = new_dir / f"{src.stem}.legacy{counter}{src.suffix}"
|
||
counter += 1
|
||
|
||
try:
|
||
# shutil.move falls back to copy+unlink on EXDEV (cross-device),
|
||
# which Path.rename does not handle — required for setups where
|
||
# /config/www is a separate mount (Docker bind, LXC, NFS).
|
||
shutil.move(str(src), str(dest))
|
||
moved += 1
|
||
except OSError as err:
|
||
failed += 1
|
||
_LOGGER.error(
|
||
"Failed to migrate %s out of www/: %s. File remains "
|
||
"exposed via /local/yaml_backups/.",
|
||
src.name,
|
||
err,
|
||
)
|
||
|
||
# Remove the legacy dir if we emptied it. Narrow the swallowed errno
|
||
# to ENOTEMPTY (user-dropped non-.bak files block removal — fine);
|
||
# surface anything else (permissions, read-only FS, etc.).
|
||
try:
|
||
legacy_dir.rmdir()
|
||
except OSError as err:
|
||
# ENOTEMPTY on Linux/APFS; EEXIST on some pre-APFS macOS filesystems.
|
||
if err.errno not in (errno.ENOTEMPTY, errno.EEXIST):
|
||
_LOGGER.warning(
|
||
"Could not remove legacy backup dir %s: [%s] %s",
|
||
legacy_dir,
|
||
type(err).__name__,
|
||
err,
|
||
)
|
||
|
||
return moved, failed
|
||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||
"""Set up a config entry, dispatching on its entry type.
|
||
|
||
The integration serves two entry types under one domain: the ``tools``
|
||
services entry (the original component) and the in-process ``server`` entry
|
||
(issue #1527). A missing ``entry_type`` means ``tools`` so pre-existing
|
||
entries keep working across the component update with no migration.
|
||
"""
|
||
# Lazy import (matches the embedded chain's own convention in
|
||
# embedded_entry.py): keeps homeassistant.const's import surface out of
|
||
# every hermetic unit test that merely imports this package, not just the
|
||
# ones that actually call async_setup_entry.
|
||
from .install_source_check import async_schedule_install_source_check
|
||
|
||
# Runs for BOTH entry types: HACS delivers the component as a whole, not
|
||
# just the server entry, so a legacy-repo install needs detecting either way.
|
||
async_schedule_install_source_check(hass)
|
||
|
||
if entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
||
from .embedded_entry import async_setup_server_entry
|
||
|
||
return await async_setup_server_entry(hass, entry)
|
||
return await _async_setup_tools_entry(hass, entry)
|
||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||
"""Unload a config entry, dispatching on its entry type."""
|
||
if entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
||
from .embedded_entry import async_unload_server_entry
|
||
|
||
return await async_unload_server_entry(hass, entry)
|
||
return await _async_unload_tools_entry(hass, entry)
|
||
|
||
|
||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||
"""Handle config-entry removal, dispatching on its entry type.
|
||
|
||
The server entry revokes the credentials it provisioned; the tools entry has
|
||
nothing to clean up beyond what unload already did.
|
||
"""
|
||
if entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER:
|
||
from .embedded_entry import async_remove_server_entry
|
||
|
||
await async_remove_server_entry(hass, entry)
|
||
|
||
|
||
def _current_extra_dirs(hass: HomeAssistant) -> list[str]:
|
||
"""Return the live user-configured extra directories from hass.data."""
|
||
domain_data = hass.data.get(DOMAIN)
|
||
if isinstance(domain_data, dict):
|
||
paths = domain_data.get(_HASS_DATA_ALLOWED_PATHS_KEY)
|
||
if isinstance(paths, list):
|
||
return paths
|
||
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]]:
|
||
"""Build the handle_list_files service handler."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
|
||
async def handle_list_files(call: ServiceCall) -> ServiceResponse:
|
||
"""Handle the list_files service call."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_LIST_FILES, files=[])
|
||
rel_path = call.data["path"]
|
||
pattern = call.data.get("pattern")
|
||
|
||
# Security check. Package files may live under a non-default folder
|
||
# (issue #1854); read_file already resolves and honours the configured
|
||
# folder(s), so the lister does too — otherwise a packages folder is
|
||
# readable file-by-file but cannot be enumerated, which is what
|
||
# ha_config_get_yaml's cross-file key discovery needs (issue #1788).
|
||
extra_dirs = _current_extra_dirs(hass)
|
||
package_dirs = await _detect_package_dirs(hass)
|
||
if not _is_path_allowed_for_dir(
|
||
config_dir, rel_path, ALLOWED_READ_DIRS, extra_dirs, package_dirs
|
||
):
|
||
_LOGGER.warning("Attempted to list files in disallowed path: %s", rel_path)
|
||
allowed_display = ALLOWED_READ_DIRS + sorted(package_dirs) + extra_dirs
|
||
return {
|
||
"success": False,
|
||
"error": f"Path not allowed. Must be in: {', '.join(allowed_display)}",
|
||
"files": [],
|
||
}
|
||
|
||
target_dir = config_dir / rel_path
|
||
|
||
try:
|
||
result = await hass.async_add_executor_job(
|
||
_list_files_sync, target_dir, config_dir, pattern
|
||
)
|
||
except PermissionError:
|
||
_LOGGER.error("Permission denied accessing: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Permission denied: {rel_path}",
|
||
"files": [],
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error listing files in %s: %s", rel_path, err)
|
||
return {
|
||
"success": False,
|
||
"error": str(err),
|
||
"files": [],
|
||
}
|
||
|
||
err_kind = result.get("_error")
|
||
if err_kind == "not_found":
|
||
return {
|
||
"success": False,
|
||
"error": f"Directory does not exist: {rel_path}",
|
||
"files": [],
|
||
}
|
||
if err_kind == "not_a_dir":
|
||
return {
|
||
"success": False,
|
||
"error": f"Path is not a directory: {rel_path}",
|
||
"files": [],
|
||
}
|
||
|
||
files = result["files"]
|
||
return {
|
||
"success": True,
|
||
"path": rel_path,
|
||
"pattern": pattern,
|
||
"files": files,
|
||
"count": len(files),
|
||
}
|
||
|
||
return handle_list_files
|
||
|
||
|
||
async def _shape_read_file_response(
|
||
hass: HomeAssistant,
|
||
rel_path: str,
|
||
result: dict[str, Any],
|
||
*,
|
||
tail_lines: int | None,
|
||
yaml_path: str | None,
|
||
include_parsed: bool,
|
||
) -> dict[str, Any]:
|
||
"""Shape a successful _read_file_sync result into the read_file response.
|
||
|
||
Applies secrets masking, log tailing, optional tail, and yaml_path
|
||
subtree extraction.
|
||
"""
|
||
modified_dt = datetime.fromtimestamp(result["mtime"])
|
||
content = result["content"]
|
||
stat_size = result["size"]
|
||
|
||
# Apply special handling for specific files
|
||
normalized = os.path.normpath(rel_path) # noqa: ASYNC240
|
||
|
||
# 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 = await hass.async_add_executor_job(_mask_secrets_content, content)
|
||
|
||
# Apply tail for log files
|
||
if normalized == "home-assistant.log":
|
||
lines = content.split("\n")
|
||
limit = tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES
|
||
if len(lines) > limit:
|
||
content = "\n".join(lines[-limit:])
|
||
truncated = True
|
||
else:
|
||
truncated = False
|
||
|
||
return {
|
||
"success": True,
|
||
"path": rel_path,
|
||
"content": content,
|
||
"size": stat_size,
|
||
"modified": modified_dt.isoformat(),
|
||
"lines_returned": min(len(lines), limit),
|
||
"total_lines": len(lines),
|
||
"truncated": truncated,
|
||
}
|
||
|
||
# Apply tail for other files if requested
|
||
full_content = content
|
||
if tail_lines:
|
||
lines = content.split("\n")
|
||
if len(lines) > tail_lines:
|
||
content = "\n".join(lines[-tail_lines:])
|
||
|
||
response: dict[str, Any] = {
|
||
"success": True,
|
||
"path": rel_path,
|
||
"content": content,
|
||
"size": stat_size,
|
||
"modified": modified_dt.isoformat(),
|
||
}
|
||
if yaml_path:
|
||
# Extract from the UNTAILED text: tailing is a display concern, and
|
||
# the retained tail is both likely to exclude the key and likely to
|
||
# be invalid YAML on its own, which would report the key as absent.
|
||
response.update(
|
||
await hass.async_add_executor_job(
|
||
_extract_yaml_views, full_content, yaml_path, include_parsed
|
||
)
|
||
)
|
||
return response
|
||
|
||
|
||
def _build_read_file_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_read_file service handler."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
|
||
async def handle_read_file(call: ServiceCall) -> ServiceResponse:
|
||
"""Handle the read_file service call."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_READ_FILE)
|
||
rel_path = call.data["path"]
|
||
tail_lines = call.data.get("tail_lines")
|
||
yaml_path = call.data.get("yaml_path")
|
||
include_parsed = bool(call.data.get("include_parsed", False))
|
||
|
||
# Security check. Package files may live under a non-default folder
|
||
# (issue #1854), so resolve the configured folder(s) — the same way the
|
||
# YAML editor does — and honour them here too, otherwise the pre-write
|
||
# backup snapshot (a read of the target) blocks edits to those files.
|
||
extra_dirs = _current_extra_dirs(hass)
|
||
package_dirs = await _detect_package_dirs(hass)
|
||
if not _is_path_allowed_for_read(
|
||
config_dir, rel_path, extra_dirs, package_dirs
|
||
):
|
||
_LOGGER.warning("Attempted to read disallowed path: %s", rel_path)
|
||
allowed_patterns = (
|
||
ALLOWED_READ_FILES
|
||
+ [f"{d}/**" for d in ALLOWED_READ_DIRS]
|
||
+ [f"{d}/*.yaml" for d in sorted(package_dirs)]
|
||
+ ["custom_components/**/*.py"]
|
||
+ [f"{d}/**" for d in extra_dirs]
|
||
)
|
||
return {
|
||
"success": False,
|
||
"error": f"Path not allowed. Allowed patterns: {', '.join(allowed_patterns)}",
|
||
}
|
||
|
||
target_file = config_dir / rel_path
|
||
|
||
try:
|
||
result = await hass.async_add_executor_job(_read_file_sync, target_file)
|
||
except PermissionError:
|
||
_LOGGER.error("Permission denied reading: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Permission denied: {rel_path}",
|
||
}
|
||
except UnicodeDecodeError:
|
||
_LOGGER.error("Cannot read binary file: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Cannot read binary file: {rel_path}. Only text files are supported.",
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error reading file %s: %s", rel_path, err)
|
||
return {
|
||
"success": False,
|
||
"error": str(err),
|
||
}
|
||
|
||
err_kind = result.get("_error")
|
||
if err_kind == "not_found":
|
||
return {
|
||
"success": False,
|
||
"error": f"File does not exist: {rel_path}",
|
||
}
|
||
if err_kind == "not_a_file":
|
||
return {
|
||
"success": False,
|
||
"error": f"Path is not a file: {rel_path}",
|
||
}
|
||
|
||
return await _shape_read_file_response(
|
||
hass,
|
||
rel_path,
|
||
result,
|
||
tail_lines=tail_lines,
|
||
yaml_path=yaml_path,
|
||
include_parsed=include_parsed,
|
||
)
|
||
|
||
return handle_read_file
|
||
|
||
|
||
def _build_write_file_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_write_file service handler."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
|
||
async def handle_write_file(call: ServiceCall) -> ServiceResponse:
|
||
"""Handle the write_file service call."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_WRITE_FILE)
|
||
rel_path = call.data["path"]
|
||
content = call.data["content"]
|
||
overwrite = call.data.get("overwrite", False)
|
||
create_dirs = call.data.get("create_dirs", True)
|
||
|
||
# Security check - only allow writes to specific directories
|
||
extra_dirs = _current_extra_dirs(hass)
|
||
if not _is_path_allowed_for_dir(
|
||
config_dir, rel_path, ALLOWED_WRITE_DIRS, extra_dirs
|
||
):
|
||
_LOGGER.warning("Attempted to write to disallowed path: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Write not allowed. Must be in: {', '.join(ALLOWED_WRITE_DIRS + extra_dirs)}",
|
||
}
|
||
|
||
target_file = config_dir / rel_path
|
||
|
||
try:
|
||
result = await hass.async_add_executor_job(
|
||
_write_file_sync,
|
||
target_file,
|
||
content,
|
||
overwrite,
|
||
create_dirs,
|
||
config_dir,
|
||
)
|
||
except PermissionError:
|
||
_LOGGER.error("Permission denied writing: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Permission denied: {rel_path}",
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error writing file %s: %s", rel_path, err)
|
||
return {
|
||
"success": False,
|
||
"error": str(err),
|
||
}
|
||
|
||
err_kind = result.get("_error")
|
||
if err_kind == "exists_no_overwrite":
|
||
return {
|
||
"success": False,
|
||
"error": f"File already exists: {rel_path}. Set overwrite=true to replace.",
|
||
}
|
||
if err_kind == "no_parent":
|
||
return {
|
||
"success": False,
|
||
"error": f"Parent directory does not exist: {result['parent']}",
|
||
}
|
||
|
||
size = result["size"]
|
||
modified_dt = datetime.fromtimestamp(result["mtime"])
|
||
is_new = result["is_new"]
|
||
|
||
_LOGGER.info("Wrote file: %s (%d bytes)", rel_path, size)
|
||
|
||
return {
|
||
"success": True,
|
||
"path": rel_path,
|
||
"size": size,
|
||
"modified": modified_dt.isoformat(),
|
||
"created": is_new,
|
||
"message": f"File {'created' if is_new else 'updated'} successfully",
|
||
}
|
||
|
||
return handle_write_file
|
||
|
||
|
||
def _build_delete_file_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_delete_file service handler."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
|
||
async def handle_delete_file(call: ServiceCall) -> ServiceResponse:
|
||
"""Handle the delete_file service call."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_DELETE_FILE)
|
||
rel_path = call.data["path"]
|
||
|
||
# Security check - only allow deletes from specific directories
|
||
extra_dirs = _current_extra_dirs(hass)
|
||
if not _is_path_allowed_for_dir(
|
||
config_dir, rel_path, ALLOWED_WRITE_DIRS, extra_dirs
|
||
):
|
||
_LOGGER.warning("Attempted to delete from disallowed path: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Delete not allowed. Must be in: {', '.join(ALLOWED_WRITE_DIRS + extra_dirs)}",
|
||
}
|
||
|
||
target_file = config_dir / rel_path
|
||
|
||
try:
|
||
result = await hass.async_add_executor_job(_delete_file_sync, target_file)
|
||
except PermissionError:
|
||
_LOGGER.error("Permission denied deleting: %s", rel_path)
|
||
return {
|
||
"success": False,
|
||
"error": f"Permission denied: {rel_path}",
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error deleting file %s: %s", rel_path, err)
|
||
return {
|
||
"success": False,
|
||
"error": str(err),
|
||
}
|
||
|
||
err_kind = result.get("_error")
|
||
if err_kind == "not_found":
|
||
return {
|
||
"success": False,
|
||
"error": f"File does not exist: {rel_path}",
|
||
}
|
||
if err_kind == "not_a_file":
|
||
return {
|
||
"success": False,
|
||
"error": f"Path is not a file (cannot delete directories): {rel_path}",
|
||
}
|
||
|
||
size = result["size"]
|
||
_LOGGER.info("Deleted file: %s (%d bytes)", rel_path, size)
|
||
|
||
return {
|
||
"success": True,
|
||
"path": rel_path,
|
||
"deleted_size": size,
|
||
"message": f"File deleted successfully: {rel_path}",
|
||
}
|
||
|
||
return handle_delete_file
|
||
|
||
|
||
def _build_get_caller_token_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_get_caller_token service handler."""
|
||
|
||
async def handle_get_caller_token(call: ServiceCall) -> ServiceResponse:
|
||
"""Return the caller-auth token to the ha-mcp bootstrap caller.
|
||
|
||
Admin-gated explicitly (see `_caller_is_admin`): HA's service
|
||
registry has no built-in admin requirement, so the gate prevents
|
||
a non-admin caller from bootstrapping the token. The supported
|
||
deployments (addon supervisor user, standalone admin LLAT) all
|
||
pass this gate.
|
||
"""
|
||
if not await _caller_is_admin(hass, call):
|
||
return {
|
||
"success": False,
|
||
"error_code": "unauthorized",
|
||
"error": "ha_mcp_tools.get_caller_token requires admin auth.",
|
||
}
|
||
domain_data = hass.data.get(DOMAIN)
|
||
token = (
|
||
domain_data.get(_HASS_DATA_TOKEN_KEY)
|
||
if isinstance(domain_data, dict)
|
||
else None
|
||
)
|
||
if not isinstance(token, str) or not token:
|
||
return {
|
||
"success": False,
|
||
"error_code": "not_initialized",
|
||
"error": (
|
||
"Caller token not initialized — integration may not have "
|
||
"completed setup. Reload the ha_mcp_tools integration."
|
||
),
|
||
}
|
||
# Report the manifest version so ha-mcp can enforce a minimum
|
||
# compatible component version. The integration loader reads
|
||
# ``manifest.json`` once at startup; ``async_get_integration``
|
||
# is cheap and avoids hard-coding the version twice.
|
||
# Pathological-but-belt-and-suspenders: a corrupted manifest
|
||
# would otherwise surface as HA's generic handler error rather
|
||
# than the actionable structured response shape this service
|
||
# 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:
|
||
_LOGGER.warning(
|
||
"Could not read ha_mcp_tools manifest version for "
|
||
"get_caller_token response: %s",
|
||
exc,
|
||
)
|
||
return {
|
||
"success": False,
|
||
"error_code": "manifest_unreadable",
|
||
"error": (
|
||
"ha_mcp_tools manifest version could not be read. "
|
||
"Reinstall the integration via HACS."
|
||
),
|
||
}
|
||
return {
|
||
"success": True,
|
||
"token": token,
|
||
"version": version,
|
||
}
|
||
|
||
return handle_get_caller_token
|
||
|
||
|
||
def _build_get_allowed_paths_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_get_allowed_paths service handler."""
|
||
|
||
async def handle_get_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
||
"""Return the user-configurable extra directories plus the built-in
|
||
allowlists and the non-overridable deny floor (issue #1567).
|
||
|
||
Backs the ha-mcp settings UI's custom filesystem-directory editor.
|
||
Caller-token + admin gated (matching get_caller_token): it reveals the
|
||
filesystem layout, so it is not a public read.
|
||
"""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_GET_ALLOWED_PATHS, paths=[])
|
||
if not await _caller_is_admin(hass, call):
|
||
return {
|
||
"success": False,
|
||
"error_code": "unauthorized",
|
||
"error": "ha_mcp_tools.get_allowed_paths requires admin auth.",
|
||
"paths": [],
|
||
}
|
||
return {
|
||
"success": True,
|
||
"paths": _current_extra_dirs(hass),
|
||
"builtin_read_dirs": list(ALLOWED_READ_DIRS),
|
||
"builtin_write_dirs": list(ALLOWED_WRITE_DIRS),
|
||
"deny_floor": sorted(DENY_PATH_SEGMENTS | DENY_READ_BASENAMES),
|
||
}
|
||
|
||
return handle_get_allowed_paths
|
||
|
||
|
||
def _build_set_allowed_paths_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_set_allowed_paths service handler."""
|
||
|
||
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
||
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
||
|
||
Receives the FULL replacement list. Each entry is validated and
|
||
normalized; entries that use traversal, escape the config directory, or
|
||
hit the non-overridable deny floor are dropped and reported in
|
||
``rejected``. Config-relative dirs (#1567) and the fixed HAOS
|
||
sibling-volume roots (#1586 — see ``const.ALLOWED_VOLUME_ROOTS``) are
|
||
accepted; any other absolute path is dropped. 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_ALLOWED_PATHS, paths=[])
|
||
if not await _caller_is_admin(hass, call):
|
||
return {
|
||
"success": False,
|
||
"error_code": "unauthorized",
|
||
"error": "ha_mcp_tools.set_allowed_paths requires admin auth.",
|
||
"paths": [],
|
||
}
|
||
normalized, rejected = await _apply_allowed_paths(
|
||
hass, call.data.get("paths", [])
|
||
)
|
||
_LOGGER.info(
|
||
"Updated ha_mcp_tools custom filesystem directories: %s (%d rejected)",
|
||
normalized,
|
||
len(rejected),
|
||
)
|
||
return {
|
||
"success": True,
|
||
"paths": normalized,
|
||
"rejected": rejected,
|
||
}
|
||
|
||
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]]:
|
||
"""Build the handle_list_legacy_backups service handler."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
legacy_backup_dir = config_dir / _LEGACY_BACKUP_DIRNAME
|
||
|
||
async def handle_list_legacy_backups(call: ServiceCall) -> ServiceResponse:
|
||
"""List pre-#1579 YAML backups in .ha_mcp_tools_backups/ (read-only)."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_LIST_LEGACY_BACKUPS, backups=[])
|
||
try:
|
||
backups = await hass.async_add_executor_job(
|
||
_list_legacy_backups_sync, legacy_backup_dir
|
||
)
|
||
except OSError as err:
|
||
_LOGGER.error("Error listing legacy backups: %s", err)
|
||
return {"success": False, "error": str(err), "backups": []}
|
||
return {"success": True, "backups": backups, "count": len(backups)}
|
||
|
||
return handle_list_legacy_backups
|
||
|
||
|
||
def _build_read_legacy_backup_handler(
|
||
hass: HomeAssistant,
|
||
) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]:
|
||
"""Build the handle_read_legacy_backup service handler."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
legacy_backup_dir = config_dir / _LEGACY_BACKUP_DIRNAME
|
||
|
||
async def handle_read_legacy_backup(call: ServiceCall) -> ServiceResponse:
|
||
"""Read one pre-#1579 YAML backup by filename (read-only)."""
|
||
if not _caller_token_ok(hass, call):
|
||
return _unauthorized_response(SERVICE_READ_LEGACY_BACKUP)
|
||
filename = call.data["filename"]
|
||
# Bare basename only, .bak only, confined to the legacy dir. Reject any
|
||
# path separator, traversal, or symlink escape before touching the FS —
|
||
# this surface only ever serves files inside .ha_mcp_tools_backups/.
|
||
if (
|
||
not filename
|
||
or "/" in filename
|
||
or "\\" in filename
|
||
or filename in (".", "..")
|
||
or not filename.endswith(".bak")
|
||
or not _resolves_within(legacy_backup_dir, filename)
|
||
):
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"Invalid backup filename {filename!r}. Pass a bare "
|
||
f"<name>.bak from {_LEGACY_BACKUP_DIRNAME}/ "
|
||
"(no path separators)."
|
||
),
|
||
}
|
||
target_file = legacy_backup_dir / filename
|
||
try:
|
||
result = await hass.async_add_executor_job(
|
||
_read_legacy_backup_sync, target_file
|
||
)
|
||
except UnicodeDecodeError:
|
||
_LOGGER.error("Cannot read binary legacy backup: %s", filename)
|
||
return {
|
||
"success": False,
|
||
"error": f"Cannot read binary backup: {filename}.",
|
||
}
|
||
except OSError as err:
|
||
_LOGGER.error("Error reading legacy backup %s: %s", filename, err)
|
||
return {"success": False, "error": str(err)}
|
||
|
||
err_kind = result.get("_error")
|
||
if err_kind == "not_found":
|
||
return {"success": False, "error": f"Backup does not exist: {filename}"}
|
||
if err_kind == "not_a_file":
|
||
return {"success": False, "error": f"Path is not a file: {filename}"}
|
||
|
||
modified_dt = datetime.fromtimestamp(result["mtime"])
|
||
decoded = _decode_legacy_backup_name(filename)
|
||
return {
|
||
"success": True,
|
||
"filename": filename,
|
||
"file_path": decoded["file_path"],
|
||
"path_ambiguous": decoded["path_ambiguous"],
|
||
"timestamp": decoded["timestamp"],
|
||
"content": result["content"],
|
||
"size": result["size"],
|
||
"modified": modified_dt.isoformat(),
|
||
}
|
||
|
||
return handle_read_legacy_backup
|
||
|
||
|
||
async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||
"""Set up the File & YAML services (tools entry) from a config entry."""
|
||
config_dir = Path(hass.config.config_dir)
|
||
|
||
# Bootstrap the caller-auth token. Generated on first setup, persisted
|
||
# to .storage/ha_mcp_tools_auth, cached in hass.data for fast handler
|
||
# access. ha-mcp fetches it via the get_caller_token service.
|
||
caller_token = await _load_or_create_caller_token(hass)
|
||
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_TOKEN_KEY] = caller_token
|
||
|
||
# Load the user-configurable extra read/write directories (issue #1567)
|
||
# into hass.data so the file handlers read them with no I/O. set_allowed_paths
|
||
# updates this in place, so changes apply live (no HA restart).
|
||
hass.data.setdefault(DOMAIN, {})[
|
||
_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
|
||
# normal value (file ops, edit_yaml_config) is unaffected by this.
|
||
try:
|
||
moved, failed = await hass.async_add_executor_job(
|
||
_migrate_legacy_backup_dir, config_dir
|
||
)
|
||
except Exception as err:
|
||
# Defensive: a migration failure must not block setup_entry, since
|
||
# the integration's normal value (file ops, edit_yaml_config) is
|
||
# unaffected by whether old backups got relocated.
|
||
_LOGGER.error(
|
||
"GHSA-g39v-cvjh-8fpf migration failed: %s. Pre-fix backups "
|
||
"may still be present in www/yaml_backups/ and reachable "
|
||
"via /local/yaml_backups/ — manual cleanup required.",
|
||
err,
|
||
)
|
||
moved, failed = 0, 0
|
||
|
||
if moved or failed:
|
||
if failed:
|
||
heading = (
|
||
f"Migrated {moved} YAML backup file(s); **{failed} could "
|
||
"not be moved** and remain in `www/yaml_backups/`, still "
|
||
"reachable without authentication via `/local/yaml_backups/`. "
|
||
"Move or delete them manually before continuing."
|
||
)
|
||
else:
|
||
heading = (
|
||
f"Moved {moved} YAML backup file(s) from `www/yaml_backups/` "
|
||
"to `.ha_mcp_tools_backups/`. The previous location was "
|
||
"reachable without authentication via `/local/yaml_backups/`."
|
||
)
|
||
message = (
|
||
f"{heading} See "
|
||
"[GHSA-g39v-cvjh-8fpf](https://github.com/homeassistant-ai/ha-mcp/security/advisories/GHSA-g39v-cvjh-8fpf). "
|
||
"**Rotate any secrets** that appeared in those YAML files "
|
||
"(MQTT/REST credentials, webhook IDs, `shell_command` "
|
||
"definitions, geofence coordinates). If you version-control "
|
||
"your Home Assistant config, also add `.ha_mcp_tools_backups/` "
|
||
"to your `.gitignore` so future backups are not committed."
|
||
)
|
||
_LOGGER.error(message)
|
||
try:
|
||
persistent_notification.async_create(
|
||
hass,
|
||
message,
|
||
title="HA MCP Tools — credential exposure (GHSA-g39v-cvjh-8fpf)",
|
||
notification_id="ha_mcp_tools_ghsa_g39v_cvjh_8fpf",
|
||
)
|
||
except Exception as err:
|
||
# Defensive: log line above is the source of truth; the
|
||
# notification is best-effort UX and must not block setup.
|
||
_LOGGER.warning(
|
||
"Could not create persistent notification for "
|
||
"GHSA-g39v-cvjh-8fpf migration: [%s] %s",
|
||
type(err).__name__,
|
||
err,
|
||
)
|
||
|
||
handle_list_files = _build_list_files_handler(hass)
|
||
handle_read_file = _build_read_file_handler(hass)
|
||
handle_write_file = _build_write_file_handler(hass)
|
||
handle_delete_file = _build_delete_file_handler(hass)
|
||
handle_edit_yaml_config = _build_edit_yaml_config_handler(hass)
|
||
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)
|
||
|
||
# Register all services with response support
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_EDIT_YAML_CONFIG,
|
||
handle_edit_yaml_config,
|
||
schema=SERVICE_EDIT_YAML_CONFIG_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_LIST_FILES,
|
||
handle_list_files,
|
||
schema=SERVICE_LIST_FILES_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_READ_FILE,
|
||
handle_read_file,
|
||
schema=SERVICE_READ_FILE_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_WRITE_FILE,
|
||
handle_write_file,
|
||
schema=SERVICE_WRITE_FILE_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_DELETE_FILE,
|
||
handle_delete_file,
|
||
schema=SERVICE_DELETE_FILE_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_GET_CALLER_TOKEN,
|
||
handle_get_caller_token,
|
||
schema=SERVICE_GET_CALLER_TOKEN_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_GET_ALLOWED_PATHS,
|
||
handle_get_allowed_paths,
|
||
schema=SERVICE_GET_ALLOWED_PATHS_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_SET_ALLOWED_PATHS,
|
||
handle_set_allowed_paths,
|
||
schema=SERVICE_SET_ALLOWED_PATHS_SCHEMA,
|
||
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,
|
||
handle_list_legacy_backups,
|
||
schema=SERVICE_LIST_LEGACY_BACKUPS_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
hass.services.async_register(
|
||
DOMAIN,
|
||
SERVICE_READ_LEGACY_BACKUP,
|
||
handle_read_legacy_backup,
|
||
schema=SERVICE_READ_LEGACY_BACKUP_SCHEMA,
|
||
supports_response=SupportsResponse.ONLY,
|
||
)
|
||
|
||
# Register the in-process ha_mcp_tools/* WebSocket commands (info + search)
|
||
# the server calls behind a capability gate. Idempotent, and independent of
|
||
# the caller-token gate above — HA core authenticates the WS connection and
|
||
# @require_admin gates each command (see websocket_api.py).
|
||
async_register_commands(hass)
|
||
|
||
# Entry-level finalization: pick up the #1853 rename on existing installs and
|
||
# give the tools entry a device. Both are cosmetic to the integration's core
|
||
# value; the one fallible step (the manifest version read below) degrades to
|
||
# the compiled-in fallback rather than blocking setup.
|
||
# Retitle an entry still carrying the pre-rename default; a user-customized
|
||
# title is left untouched (only the exact old default migrates). The tools
|
||
# entry registers no update listener, so this async_update_entry cannot
|
||
# trigger a reload loop — and the guard is idempotent regardless (the title
|
||
# no longer matches on the next setup).
|
||
if entry.title == TOOLS_ENTRY_LEGACY_TITLE:
|
||
hass.config_entries.async_update_entry(entry, title=TOOLS_ENTRY_TITLE)
|
||
|
||
# Register a device (parity with the server entry, which gets one via
|
||
# update.py's DeviceInfo). Tied to the config entry, so HA removes it with
|
||
# the entry — no unload cleanup needed. The component version comes from the
|
||
# manifest like the options-form version hint, degrading to the compiled-in
|
||
# COMPONENT_VERSION if that read fails so a manifest hiccup never breaks setup.
|
||
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, using "
|
||
"the compiled-in %s: %s",
|
||
COMPONENT_VERSION,
|
||
err,
|
||
)
|
||
dr.async_get(hass).async_get_or_create(
|
||
config_entry_id=entry.entry_id,
|
||
identifiers={(DOMAIN, entry.entry_id)},
|
||
name=TOOLS_ENTRY_TITLE,
|
||
manufacturer="homeassistant-ai",
|
||
model="File & YAML editing services",
|
||
sw_version=component_version,
|
||
configuration_url="https://github.com/homeassistant-ai/ha-mcp",
|
||
)
|
||
|
||
_LOGGER.info("HA-MCP File & YAML Tools initialized with file management services")
|
||
return True
|
||
|
||
|
||
async def _async_unload_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||
"""Unload the File & YAML services (tools entry) config entry."""
|
||
# Remove all services
|
||
hass.services.async_remove(DOMAIN, SERVICE_EDIT_YAML_CONFIG)
|
||
hass.services.async_remove(DOMAIN, SERVICE_LIST_FILES)
|
||
hass.services.async_remove(DOMAIN, SERVICE_READ_FILE)
|
||
hass.services.async_remove(DOMAIN, SERVICE_WRITE_FILE)
|
||
hass.services.async_remove(DOMAIN, SERVICE_DELETE_FILE)
|
||
hass.services.async_remove(DOMAIN, SERVICE_GET_CALLER_TOKEN)
|
||
hass.services.async_remove(DOMAIN, SERVICE_GET_ALLOWED_PATHS)
|
||
hass.services.async_remove(DOMAIN, SERVICE_SET_ALLOWED_PATHS)
|
||
hass.services.async_remove(DOMAIN, SERVICE_LIST_LEGACY_BACKUPS)
|
||
hass.services.async_remove(DOMAIN, SERVICE_READ_LEGACY_BACKUP)
|
||
|
||
# Drop the cached token + allowlist from hass.data so a subsequent
|
||
# setup_entry re-reads from storage (covers the reload-after-rotate path).
|
||
domain_data = hass.data.get(DOMAIN)
|
||
if isinstance(domain_data, dict):
|
||
domain_data.pop(_HASS_DATA_TOKEN_KEY, None)
|
||
domain_data.pop(_HASS_DATA_ALLOWED_PATHS_KEY, None)
|
||
return True
|