3285 lines
128 KiB
Python
3285 lines
128 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_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
|
|
``<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
|
|
)
|
|
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 _parse_and_validate_yaml_path(
|
|
yaml_path: str,
|
|
*,
|
|
is_package: bool = False,
|
|
is_theme: bool = False,
|
|
) -> 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.
|
|
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]
|
|
if key in ALLOWED_YAML_KEYS:
|
|
return "single", parts, None
|
|
if is_package and key in PACKAGES_ONLY_YAML_KEYS:
|
|
return "single", parts, None
|
|
# Reaching here means the key was not accepted. If it is a
|
|
# PACKAGES_ONLY key, we know is_package=False (otherwise the
|
|
# preceding branch would have returned) — emit the targeted
|
|
# "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
|
|
if is_package
|
|
else ALLOWED_YAML_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 _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
|
|
if normalized == "secrets.yaml":
|
|
content = _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)
|
|
version = str(integration.version)
|
|
except Exception as exc: # pragma: no cover — manifest sanity
|
|
_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."""
|
|
config_dir = Path(hass.config.config_dir)
|
|
|
|
async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse:
|
|
"""Replace the user-configurable extra directories (issues #1567, #1586).
|
|
|
|
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": [],
|
|
}
|
|
raw_paths = call.data.get("paths", [])
|
|
normalized: list[str] = []
|
|
rejected: list[str] = []
|
|
for entry in raw_paths:
|
|
norm = _normalize_extra_dir(entry, config_dir)
|
|
if norm is None:
|
|
rejected.append(entry)
|
|
elif norm not in normalized:
|
|
normalized.append(norm)
|
|
|
|
await _save_allowed_paths(hass, normalized)
|
|
hass.data.setdefault(DOMAIN, {})[_HASS_DATA_ALLOWED_PATHS_KEY] = normalized
|
|
_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_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)
|
|
|
|
# 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_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_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)
|
|
component_version = str(integration.version)
|
|
except Exception as err:
|
|
_LOGGER.debug(
|
|
"Could not read the component version for the tools device: %s", 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
|