"""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_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" # 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" # 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] ), # 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, } ) 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 _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 ``-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 (``//..``) 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 ``//..`` 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 `` (or ``!include_dir_merge_named``), where ```` 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 `//../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/ - Files matching patterns: /*.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 `//..` 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 /*.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": } 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: "._.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.+)\.(?P\d{8})_(?P