"""Guarded YAML access for Home Assistant configuration files. The language model is untrusted. Reads are bounded and redact likely inline credentials. Every mutation is previewed first, bound to the exact current file hash, backed up, written atomically, checked by Home Assistant and rolled back when validation or reload fails. ``secrets.yaml`` is never addressable. """ from __future__ import annotations import copy import difflib import hashlib import json import os import re import secrets import time from pathlib import Path from typing import Any from homeassistant.core import HomeAssistant from homeassistant.util import slugify from ..identity import user_context from ..protocol import ToolError, internal_error from ..registry import LIMIT_FIELD, OFFSET_FIELD, paginate, schema, tool # kind -> (filename, parsed structure, reload service domain) _KINDS: dict[str, tuple[str, str, str | None]] = { "automation": ("automations.yaml", "list", "automation"), "script": ("scripts.yaml", "dict", "script"), "scene": ("scenes.yaml", "list", "scene"), # configuration.yaml is intentionally raw-read/replace only. Treating its # top-level keys as CRUD records would be dangerously misleading. "configuration": ("configuration.yaml", "dict", None), } _STRUCTURED_KINDS = frozenset({"automation", "script", "scene"}) _OPS = ( "list", "get", "read_source", "find_source", "list_backups", "create", "update", "delete", "replace_source_text", "restore_backup", "reload", ) _MUTATING_OPS = frozenset({"create", "update", "delete", "replace_source_text", "restore_backup"}) _TICKET_TTL_SECONDS = 600 _MAX_SOURCE_BYTES = 2 * 1024 * 1024 _MAX_REPLACEMENT_CHARS = 50_000 _PENDING: dict[str, dict[str, Any]] = {} _SENSITIVE_LINE = re.compile( r"(?i)^(?P\s*[^#\n]*(?:password|passwd|token|secret|api[_-]?key|authorization)[^:]*:\s*).*$" ) _SECRET_REFERENCE = re.compile(r"!secret\s+[^\s#]+", re.IGNORECASE) @tool( name="ha_yaml_config", description=( "Safely inspect and edit Home Assistant YAML. Structured CRUD is limited to " "automations.yaml, scripts.yaml and scenes.yaml. Raw source operations also " "allow configuration.yaml so commented-out blocks can be found and reviewed. " "secrets.yaml and arbitrary paths are impossible. Use read_source/find_source " "for comments or exact YAML text. Every mutation first returns a diff/preview " "and one-time approval_ticket; only repeat the exact unchanged call with " "confirm=true after explicit user approval. Writes create a backup, are atomic, " "run Home Assistant config validation, roll back on failure, and reload the " "affected domain when supported. Never claim a preview changed Home Assistant." ), input_schema=schema( properties={ "kind": {"type": "string", "enum": list(_KINDS)}, "op": {"type": "string", "enum": list(_OPS)}, "id": { "type": "string", "description": "Entry id for structured get/create/update/delete.", }, "config": { "type": "object", "additionalProperties": True, "description": "Complete entry config for structured create/update.", }, "query": { "type": "string", "maxLength": 500, "description": "Case-insensitive literal text for find_source.", }, "start_line": { "type": "integer", "minimum": 1, "default": 1, "description": "First 1-based line returned by read_source.", }, "max_lines": { "type": "integer", "minimum": 1, "maximum": 400, "default": 120, "description": "Bounded source lines returned by read_source/find_source.", }, "old_text": { "type": "string", "minLength": 1, "maxLength": _MAX_REPLACEMENT_CHARS, "description": "Exact unique YAML source text to replace.", }, "new_text": { "type": "string", "maxLength": _MAX_REPLACEMENT_CHARS, "description": "Replacement YAML source text; may be empty to remove a block.", }, "backup_id": { "type": "string", "pattern": r"^[a-z0-9_.-]+$", "description": "Opaque filename returned by list_backups.", }, "confirm": { "type": "boolean", "default": False, "description": "True only after the user approved the exact preview.", }, "approval_ticket": { "type": "string", "description": "One-time ticket from the unchanged mutation preview.", }, "limit": LIMIT_FIELD, "offset": OFFSET_FIELD, }, required=["kind", "op"], ), read_only=False, idempotent=False, requires_admin=True, write_ops=["create", "update", "replace_source_text", "reload"], destructive_ops=["delete", "restore_backup"], admin_ops=["list", "get", "read_source", "find_source", "list_backups"], ) async def ha_yaml_config( hass: HomeAssistant, kind: str, op: str, id: str | None = None, config: dict[str, Any] | None = None, query: str | None = None, start_line: int = 1, max_lines: int = 120, old_text: str | None = None, new_text: str | None = None, backup_id: str | None = None, confirm: bool = False, approval_ticket: str | None = None, limit: int = 100, offset: int = 0, ) -> dict[str, Any]: if kind not in _KINDS: raise ToolError(f"unknown kind '{kind}'") if op not in _OPS: raise ToolError(f"unknown op '{op}'") filename, structure, reload_domain = _KINDS[kind] path = Path(hass.config.path(filename)) if op in {"list", "get", "create", "update", "delete", "reload"} and kind not in _STRUCTURED_KINDS: raise ToolError( "configuration.yaml supports only read_source, find_source, list_backups, " "replace_source_text and restore_backup" ) if op == "read_source": return await _read_source(hass, path, start_line, max_lines) if op == "find_source": if not query: raise ToolError("op=find_source requires query") return await _find_source(hass, path, query, max_lines) if op == "list_backups": return await _list_backups(hass, filename, limit, offset) if op == "replace_source_text": if old_text is None or new_text is None: raise ToolError("op=replace_source_text requires old_text and new_text") _reject_sensitive_replacement(old_text, new_text) current = await _read_text(hass, path) if current.count(old_text) != 1: raise ToolError( f"old_text must occur exactly once in {filename}; found {current.count(old_text)} occurrences" ) proposed = current.replace(old_text, new_text, 1) await _validate_yaml_text(hass, proposed, structure, filename) change = _change_record(kind, op, current, {"old_text": old_text, "new_text": new_text}) if not confirm: return _preview(change, _source_diff(filename, current, proposed)) _consume_ticket(change, approval_ticket) return await _commit_text(hass, path, filename, structure, reload_domain, current, proposed) if op == "restore_backup": if not backup_id: raise ToolError("op=restore_backup requires backup_id from list_backups") current = await _read_text(hass, path) restored = await _read_backup(hass, filename, backup_id) await _validate_yaml_text(hass, restored, structure, filename) change = _change_record(kind, op, current, {"backup_id": backup_id}) if not confirm: return _preview(change, _source_diff(filename, current, restored), extra={"backup_id": backup_id}) _consume_ticket(change, approval_ticket) return await _commit_text(hass, path, filename, structure, reload_domain, current, restored) data = await _load(hass, path, structure) if op == "list": return paginate(_to_list(data, structure), limit, offset) if op == "get": if not id: raise ToolError("op=get requires id") item = _find(data, structure, id) if item is None: raise ToolError(f"{kind} '{id}' not found in {filename}") return item if op == "reload": await _reload(hass, reload_domain) return {"reloaded": reload_domain, "changed_file": False} current_text = await _read_text(hass, path) proposed_data = _copy_data(data) result: dict[str, Any] if op == "create": if not config: raise ToolError("op=create requires config") # The generated id must be deterministic so the exact preview can be # confirmed in a second call without silently proposing another entry. generated_id = f"mcp_{hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()[:16]}" new_id = id or config.get("id") or generated_id if _find(proposed_data, structure, new_id) is not None: raise ToolError(f"{kind} '{new_id}' already exists") if structure == "list": proposed_data.append({"id": new_id, **{k: v for k, v in config.items() if k != "id"}}) else: proposed_data[new_id] = config result = { "operation": "create", "id": new_id, "proposed_entry": _find(proposed_data, structure, new_id), } elif op == "update": if not id or not config: raise ToolError("op=update requires id and config") before = _find(proposed_data, structure, id) if before is None or not _replace(proposed_data, structure, id, config): raise ToolError(f"{kind} '{id}' not found") result = {"operation": "update", "id": id, "current_entry": before, "proposed_entry": _find(proposed_data, structure, id)} elif op == "delete": if not id: raise ToolError("op=delete requires id") before = _find(proposed_data, structure, id) if before is None or not _remove(proposed_data, structure, id): raise ToolError(f"{kind} '{id}' not found") result = {"operation": "delete", "id": id, "current_entry": before} else: raise ToolError(f"unsupported op '{op}'") change = _change_record(kind, op, current_text, {"id": id, "config": config, "result": result}) if not confirm: return _preview(change, extra=result) _consume_ticket(change, approval_ticket) committed = await _commit_data(hass, path, filename, structure, reload_domain, current_text, proposed_data) return {**result, **committed} def _copy_data(data: Any) -> Any: return copy.deepcopy(data) def _fingerprint(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def _change_record(kind: str, op: str, current: str, arguments: dict[str, Any]) -> dict[str, Any]: return { "kind": kind, "op": op, "current_sha256": _fingerprint(current), "arguments": arguments, } def _new_ticket(change: dict[str, Any]) -> str: now = time.time() for key, value in list(_PENDING.items()): if value["expires_at"] <= now: _PENDING.pop(key, None) ticket = secrets.token_urlsafe(18) _PENDING[ticket] = { "fingerprint": _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":"))), "expires_at": now + _TICKET_TTL_SECONDS, } return ticket def _consume_ticket(change: dict[str, Any], ticket: str | None) -> None: record = _PENDING.pop(ticket, None) if ticket else None expected = _fingerprint(json.dumps(change, sort_keys=True, separators=(",", ":"))) if not record or record["expires_at"] <= time.time() or record["fingerprint"] != expected: raise ToolError( "approval_ticket is missing, expired, already used, or does not match the exact " "change/current file. Run the same operation without confirm, show the preview, " "then repeat unchanged with confirm=true only after explicit user approval." ) def _preview(change: dict[str, Any], diff: list[str] | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: return { "changed": False, "confirmation_required": True, "approval_ticket": _new_ticket(change), "ticket_expires_in_seconds": _TICKET_TTL_SECONDS, "current_sha256": change["current_sha256"], **(extra or {}), **({"diff": diff, "diff_truncated": len(diff) >= 120} if diff is not None else {}), "model_instruction": ( "This is a preview only. Show it to the user and stop. Do not claim anything was " "changed. After explicit approval repeat the exact call with confirm=true and approval_ticket." ), } def _redact_line(line: str) -> str: match = _SENSITIVE_LINE.match(line) if match: return f"{match.group('prefix')}" return _SECRET_REFERENCE.sub("!secret ", line) def _reject_sensitive_replacement(*values: str) -> None: for value in values: if any(_SENSITIVE_LINE.match(line) for line in value.splitlines()) or _SECRET_REFERENCE.search(value): raise ToolError( "Raw replacement containing credential-like keys or !secret references is refused. " "Edit that material locally outside the LLM context." ) async def _read_text(hass: HomeAssistant, path: Path) -> str: def _read() -> str: if not path.exists(): return "" if path.stat().st_size > _MAX_SOURCE_BYTES: raise ToolError(f"{path.name} exceeds the {_MAX_SOURCE_BYTES} byte safety limit") return path.read_text(encoding="utf-8") return await hass.async_add_executor_job(_read) async def _read_source(hass: HomeAssistant, path: Path, start_line: int, max_lines: int) -> dict[str, Any]: text = await _read_text(hass, path) lines = text.splitlines() start = max(1, start_line) count = max(1, min(max_lines, 400)) selected = lines[start - 1 : start - 1 + count] return { "file": path.name, "sha256": _fingerprint(text), "total_lines": len(lines), "start_line": start, "returned_lines": len(selected), "has_more": start - 1 + len(selected) < len(lines), "lines": [{"line": start + index, "text": _redact_line(line)} for index, line in enumerate(selected)], "redaction_note": "Credential-like values and !secret reference names are redacted.", } async def _find_source(hass: HomeAssistant, path: Path, query: str, max_lines: int) -> dict[str, Any]: text = await _read_text(hass, path) lines = text.splitlines() hits = [index for index, line in enumerate(lines) if query.casefold() in line.casefold()] cap = max(1, min(max_lines, 400)) selected = hits[:cap] return { "file": path.name, "sha256": _fingerprint(text), "authoritative_match_count": len(hits), "returned_count": len(selected), "has_more": len(hits) > len(selected), "matches": [{"line": index + 1, "text": _redact_line(lines[index])} for index in selected], "redaction_note": "Credential-like values and !secret reference names are redacted.", } def _source_diff(filename: str, before: str, after: str) -> list[str]: return list( difflib.unified_diff( before.splitlines(), after.splitlines(), fromfile=f"{filename}:before", tofile=f"{filename}:after", lineterm="", n=3, ) )[:120] def _backup_dir(hass: HomeAssistant) -> Path: return Path(hass.config.path(".hass_mcp_backups", "yaml")) async def _create_backup(hass: HomeAssistant, filename: str, content: str) -> str: backup_id = f"{filename}.{time.strftime('%Y%m%d-%H%M%S')}.{_fingerprint(content)[:10]}.bak" directory = _backup_dir(hass) def _write() -> None: directory.mkdir(mode=0o700, parents=True, exist_ok=True) target = directory / backup_id target.write_text(content, encoding="utf-8") target.chmod(0o600) await hass.async_add_executor_job(_write) return backup_id async def _list_backups(hass: HomeAssistant, filename: str, limit: int, offset: int) -> dict[str, Any]: directory = _backup_dir(hass) def _list() -> list[dict[str, Any]]: if not directory.exists(): return [] rows = [] for path in directory.glob(f"{filename}.*.bak"): stat = path.stat() rows.append({"backup_id": path.name, "size": stat.st_size, "created_unix": int(stat.st_mtime)}) return sorted(rows, key=lambda row: row["created_unix"], reverse=True) return paginate(await hass.async_add_executor_job(_list), limit, offset) async def _read_backup(hass: HomeAssistant, filename: str, backup_id: str) -> str: if Path(backup_id).name != backup_id or not backup_id.startswith(f"{filename}.") or not backup_id.endswith(".bak"): raise ToolError("backup_id is not valid for this YAML kind") path = _backup_dir(hass) / backup_id def _read() -> str: if not path.is_file(): raise ToolError("backup_id not found") if path.stat().st_size > _MAX_SOURCE_BYTES: raise ToolError("backup exceeds safety limit") return path.read_text(encoding="utf-8") return await hass.async_add_executor_job(_read) async def _validate_yaml_text(hass: HomeAssistant, content: str, structure: str, filename: str) -> Any: from homeassistant.util.yaml import parse_yaml def _parse() -> Any: parsed = parse_yaml(content) if content.strip() else ([] if structure == "list" else {}) if structure == "list" and not isinstance(parsed, list): raise ToolError(f"{filename} must be a YAML list, got {type(parsed).__name__}") if structure == "dict" and not isinstance(parsed, dict): raise ToolError(f"{filename} must be a YAML mapping, got {type(parsed).__name__}") return parsed return await hass.async_add_executor_job(_parse) async def _check_full_config(hass: HomeAssistant) -> dict[str, Any]: try: from homeassistant.components.config.core import async_check_ha_config_file except ImportError: from homeassistant.config import async_check_ha_config_file result = await async_check_ha_config_file(hass) if result is None: return {"valid": True} if isinstance(result, str): return {"valid": not bool(result), "error": result or None} errors = getattr(result, "errors", None) if errors: return {"valid": False, "error": str(errors)} return {"valid": True, "result": str(result)} async def _atomic_write(hass: HomeAssistant, path: Path, content: str) -> None: def _write() -> None: temporary = path.with_name(f".{path.name}.hass-mcp-{secrets.token_hex(6)}.tmp") try: with temporary.open("w", encoding="utf-8") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) os.replace(temporary, path) finally: if temporary.exists(): temporary.unlink() await hass.async_add_executor_job(_write) async def _commit_text( hass: HomeAssistant, path: Path, filename: str, structure: str, reload_domain: str | None, before: str, proposed: str, ) -> dict[str, Any]: current = await _read_text(hass, path) if _fingerprint(current) != _fingerprint(before): raise ToolError("YAML file changed after preview; refusing stale write and requiring a new preview") await _validate_yaml_text(hass, proposed, structure, filename) backup_id = await _create_backup(hass, filename, before) await _atomic_write(hass, path, proposed) validation = await _check_full_config(hass) if not validation["valid"]: await _atomic_write(hass, path, before) raise ToolError(f"Home Assistant config validation failed; original restored from {backup_id}: {validation.get('error')}") try: if reload_domain: await _reload(hass, reload_domain) except Exception: await _atomic_write(hass, path, before) if reload_domain: try: await _reload(hass, reload_domain) except Exception: pass raise readback = await _read_text(hass, path) return { "changed": readback == proposed, "file": filename, "backup_id": backup_id, "full_config_valid": True, "reloaded": reload_domain, "restart_required": reload_domain is None, "new_sha256": _fingerprint(readback), "exact_readback_match": readback == proposed, } async def _commit_data( hass: HomeAssistant, path: Path, filename: str, structure: str, reload_domain: str | None, before: str, data: Any, ) -> dict[str, Any]: from homeassistant.util.yaml import save_yaml def _render() -> str: temporary = path.with_name(f".{path.name}.hass-mcp-render-{secrets.token_hex(6)}.tmp") try: save_yaml(str(temporary), data) return temporary.read_text(encoding="utf-8") finally: if temporary.exists(): temporary.unlink() proposed = await hass.async_add_executor_job(_render) return await _commit_text(hass, path, filename, structure, reload_domain, before, proposed) async def _load(hass: HomeAssistant, path: Path, structure: str) -> Any: return await _validate_yaml_text(hass, await _read_text(hass, path), structure, path.name) async def _reload(hass: HomeAssistant, domain: str | None) -> None: if not domain: return try: await hass.services.async_call(domain, "reload", {}, blocking=True, context=user_context()) except Exception as error: raise internal_error(f"{domain}.reload failed", error) from error def _derive_entity_id(domain: str, structure: str, new_id: str, config: dict[str, Any]) -> str: slug = slugify(new_id) if structure == "dict" else slugify(config.get("alias") or new_id) return f"{domain}.{slug}" def _to_list(data: Any, structure: str) -> list[dict[str, Any]]: if structure == "list": return list(data) return [{"id": key, **value} for key, value in data.items()] def _find(data: Any, structure: str, id: str) -> dict[str, Any] | None: if structure == "list": for entry in data: if entry.get("id") == id or entry.get("alias") == id: return entry return None return {"id": id, **data[id]} if id in data else None def _replace(data: Any, structure: str, id: str, new: dict[str, Any]) -> bool: if structure == "list": for index, entry in enumerate(data): if entry.get("id") == id: data[index] = {"id": id, **{key: value for key, value in new.items() if key != "id"}} return True return False if id in data: data[id] = new return True return False def _remove(data: Any, structure: str, id: str) -> bool: if structure == "list": for index, entry in enumerate(data): if entry.get("id") == id: del data[index] return True return False if id in data: del data[id] return True return False