Skip to content

Commit c69f7eb

Browse files
authored
feat: read-only YAML fragment lookup for configuration and package keys (#1882)
* fix: honour the configured packages folder when listing files read_file resolves the packages folder bound via `homeassistant: packages:` and allows reading `*.yaml` under it (#1854), but list_files was never given the same treatment: it matched `rel_path` against ALLOWED_READ_DIRS alone, so a packages folder was readable file-by-file yet could not be enumerated. Pass the detected package dirs through to the allow decision, mirroring the read path. The widening happens inside `_is_path_allowed_for_dir`, after the deny floor and containment checks, so `.storage` and an escaping symlink stay blocked. `package_dirs` defaults to None and only the lister passes it: write_file and delete_file share this helper and must not gain package access, since edit_yaml_config is the only write path that may reach config YAML. The folder is matched literally, as `_path_in_package_dir` does, so a folder name containing glob metacharacters is not expanded. * feat: return a parsed view alongside read_file's yaml_path subtree `read_file`'s `yaml_path` returns the round-trip text of a subtree; an agent inspecting config also wants it as data. Add `include_parsed` (default off, so the existing auto-backup caller is unaffected), which returns the same subtree as JSON-safe plain Python from a single parse. HA tags are rendered to their SOURCE form (`!secret api_key`), never resolved. The value behind a `!secret` lives in secrets.yaml and is not read here, so the parsed view carries no plaintext-secret surface — the same property the text view already had. `yaml_jsonify` lives in yaml_rt.py because that module owns the tag registry. services.yaml gained `include_parsed` and, while here, `yaml_path`: the latter has been in the service schema since #1579 but was never declared, so it was invisible to the UI service picker. * feat: add ha_config_get_yaml for read-only YAML fragment lookup `ha_config_set_yaml` can edit configuration.yaml, packages/*.yaml and themes/*.yaml, but there was no matching read path: an agent had to fetch a whole file through ha_read_file and parse it, and could not find which file defines a key. Closes #1788. `ha_config_get_yaml(yaml_path, file)` returns the fragment under a key, keyed by file. `file` accepts an fnmatch glob, so `packages/*.yaml` searches a directory in one call and reports only the files that define the key — `include_content=False` reduces that to pure discovery. The returned `file` + `yaml_path` are exactly the arguments that address the same fragment for an edit, and `content` round-trips back unchanged. It lives in its own module so it can register unconditionally: reading a fragment is not an edit, and ENABLE_YAML_CONFIG_EDITING gates editing. `ha_read_file` also forwards `yaml_path` now, for the single-file case within the filesystem toolset. Two deviations from the shape proposed in the issue, both verified against the code rather than assumed: - No `config_hash`. `ha_config_set_yaml` takes no such argument — it locks with `confirm_token`, derived from the path plus the NEW content being written, which a read cannot produce. - `include_parsed` defaults off rather than on. `content` is the round-trippable view; returning both by default doubles the payload for one fragment. MIN_COMPONENT_VERSION moves to the pending 1.1.0: the glob needs the lister's packages-folder fix, and `include_parsed` is an argument an older strict schema rejects with a raw voluptuous error. Both would otherwise surface as confusing failures rather than an actionable update prompt. COMPONENT_VERSION itself is left alone — master already leads stable 1.0.4, so this rides the open pending version. * test: pin the root-level glob boundary for ha_config_get_yaml A `file` glob with no directory part resolves to the config root, which the lister denies — root files stay readable one-by-one via an explicit `file`. Pin the "." the tool sends so the deny is a deliberate boundary rather than a malformed path. * perf: fan out ha_config_get_yaml's per-file reads concurrently A glob resolved to N files and then read them one at a time, paying N sequential service round-trips to HA for what are independent reads — a packages glob is routinely 10+ files. Gather them instead, matching the fan-out pattern already used for per-item service calls elsewhere in src/ha_mcp/tools. gather preserves order, so matches stay sorted by file and the response shape is unchanged. `_unwrap_or_raise` loses its `async`: it never awaited anything. * fix: drop a provably unreachable return in ha_config_get_yaml CodeQL flagged py/unreachable-statement. The trailing `return None` was copied from the sibling tools, where it guards a different shape: their try block ends in a `raise_tool_error(...)` call that CodeQL cannot see through, so the fall-through looks reachable there. This try block ends in a real return, and `exception_to_structured_error` is typed NoReturn, so every path returns or raises and the trailer is dead code. * perf: cache packages-folder detection behind an mtime signature Every file operation resolves the configured packages folder, which parsed configuration.yaml (and whatever it includes) from disk each time. A ha_config_get_yaml glob makes that N+1 parses per search, concurrently. Cache the detection per config path, keyed on the mtimes of every file the loader actually read. The loader now records those paths, so an edit invalidates regardless of whether the packages directive sits in configuration.yaml or in an !include it follows — keying on the root file alone would serve a stale allowlist when the homeassistant: section is split out. A not-yet-existing include target is stamped -1, so creating it later invalidates too. Stat-per-file is cheap; the parse is not. Concurrent first-callers may each miss once before the entry lands, which is bounded and self-healing, and cheaper than holding a lock across executor threads. Reported by Gemini on #1882. * fix: restore the explicit None return inside ha_config_get_yaml's handler The previous commit removed both trailing returns and traded py/unreachable-statement for py/mixed-returns at the function head: CodeQL does not see that exception_to_structured_error is typed NoReturn, so with no explicit return the handler reads as falling through to an implicit None. Keep the explicit return inside the except block, and keep omitting the one after the try/except. The sibling tools carry both because their try block ends in a raise_tool_error() call CodeQL treats as returning normally, which makes their post-try trailer reachable; this try ends in a real return, so that trailer is provably dead here. * fix: do not read a broken or tailed file as a key that is absent Two ways the fragment read could report "the key is not there" when it had in fact never looked, both reported by Codex on #1882. A file whose YAML does not parse returned subtree=None, which the tool treated exactly like a file that parses but lacks the key. One broken package in a `packages/*.yaml` glob therefore read as a clean "not defined anywhere". _extract_yaml_views now also sets parse_error, and ha_config_get_yaml surfaces those files as warnings instead of silently skipping them. The error carries the position but never ruamel's message: that message embeds the offending source line, which would put file content, possibly an inline credential, into a response this path otherwise keeps free of resolved values. read_file applied tail_lines to the content BEFORE extracting yaml_path from it, so the key was looked up in the retained tail rather than the file: for a key outside the tail, or a tail that is not valid YAML alone, the subtree came back null although the key exists. Extraction now runs on the untailed text; tailing stays a display concern. The ordering predates this PR (#1579), but only the auto-backup used yaml_path and it never passes tail_lines, so surfacing yaml_path on ha_read_file is what makes the combination reachable. Covered by a component test that a parse error is reported without echoing file content, a tool test that a broken file warns rather than counting as a non-match, and an e2e that tail_lines plus yaml_path still resolves the key. * fix: gate ha_config_get_yaml behind filesystem tools; warn per file under a glob The tool returns config-file contents through the same read_file/list_files component services as ha_read_file/ha_list_files, which are gated behind enable_filesystem_tools. Registering it unconditionally handed an install that turned filesystem tools off a config-read surface anyway. It now registers behind that flag, carries the beta tag like its siblings, and has a FEATURE_GATED_TOOLS stub so it stays discoverable in the settings UI when the flag is off. The YAML *editing* flag still does not gate it: reading a fragment is not an edit. Under a glob, a file that cannot be searched no longer sinks the whole search and discards the matches already found. A success=False payload, a read that raises, and a malformed non-dict response all degrade to a warnings entry, the way a parse error already did. The glob is not restricted to *.yaml, so packages/* turning up a README made this reachable without contrivance. A single explicit file target and a list_files failure still raise, since neither has anything to salvage. yaml_jsonify: a bool carrying an anchor loads as ruamel's ScalarBoolean, which subclasses int but not bool, so it serialized as 1/0. It now has its own branch ahead of int. Non-finite floats render to their YAML source form rather than producing output that strict JSON rejects. Tests: both gating directions, the three glob degrade paths, the raise paths, an empty glob, a present-but-null key, the anchored-bool/non-finite/timestamp branches, secrets.yaml masking under the yaml_path views (source guard plus an e2e behavioural half), and the get to set round-trip that is the point of the feature. * fix: quote the secrets.yaml mask marker so it re-parses as a scalar _mask_secrets_content emitted 'key: [MASKED]'. That text is itself valid YAML and is now re-parsed: read_file's yaml_path/include_parsed views load it, and an unquoted [MASKED] is flow-sequence syntax, so the parsed view rendered the mask as the list ['MASKED'] rather than a scalar. Not a leak - the value stays masked either way - but the structured view misrepresented a security-relevant file. The marker is now quoted. Every pre-existing consumer asserts the '[MASKED]' substring against the text, which a quoted marker still satisfies; the five tests that pinned the exact line format are updated, and a new test pins the re-parse itself. Nothing re-parsed the masked text before this PR, so the wart was unreachable. The include_parsed view added here is its first consumer, and the e2e masking test the review asked for is what surfaced it. * test: cover the packages-glob skip and multi-match paths against the real component The glob's warn-and-continue path was pinned only at the unit layer, where read_file's refusal is a mock. The interaction it exists for -- the component's .yaml-only package read rule rejecting a file that `custom_packages/*` legitimately matched -- happens only against the real component, so it needs the behavioural half too. Nothing can create a non-YAML file inside a packages folder at test time: write_file is never granted package access, and a post-boot host write to the bind-mounted config dir doesn't propagate in CI. So the file is staged pre-boot in the fresh-config fixture, next to the legacy backups, and the test skips on the HAOS backends, which boot a pre-baked image carrying no such seed. HA loads an !include_dir_named folder through _find_files(loc, "*.yaml"), so the seeded .md is inert at boot. Adds the count > 1 case as well: the same key defined in two package files is the multi-match result shape the issue centers on, and no e2e produced it. * fix: annotate ha_config_get_yaml with openWorldHint The annotation became mandatory on master after this branch opened, and its default is true, so the tool would ship silently marked open-world. False: the tool's domain is the local Home Assistant instance, and it hands back the operator's own config text -- the same call the other file-read tools make.
1 parent 1ef44a5 commit c69f7eb

12 files changed

Lines changed: 1954 additions & 49 deletions

File tree

custom_components/ha_mcp_tools/__init__.py

Lines changed: 200 additions & 38 deletions
Large diffs are not rendered by default.

custom_components/ha_mcp_tools/services.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ read_file:
9696
min: 1
9797
max: 10000
9898
mode: box
99+
yaml_path:
100+
name: YAML Path
101+
description: >-
102+
Dotted key path. When set, the response also carries the round-trip
103+
text of that YAML subtree under "subtree". Comments and HA tags
104+
(!secret, !include) are preserved as written.
105+
required: false
106+
example: "alert2"
107+
selector:
108+
text:
109+
include_parsed:
110+
name: Include Parsed
111+
description: >-
112+
With yaml_path, also return the subtree as structured data under
113+
"parsed". HA tags are rendered to their source form (!secret api_key)
114+
and never resolved, so no secret value is exposed.
115+
required: false
116+
default: false
117+
selector:
118+
boolean:
99119

100120
write_file:
101121
name: Write File

custom_components/ha_mcp_tools/yaml_rt.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
from __future__ import annotations
44

5+
import math
56
import re
67
import threading
78
from collections.abc import Callable
9+
from datetime import date, datetime
810
from io import StringIO
911
from typing import Any
1012

1113
from ruamel.yaml import YAML
14+
from ruamel.yaml.scalarbool import ScalarBoolean
1215

1316

1417
class _TaggedScalar:
@@ -171,3 +174,57 @@ def yaml_dumps(ry: YAML, data: Any) -> str:
171174
buf = StringIO()
172175
ry.dump(data, buf)
173176
return buf.getvalue()
177+
178+
179+
def _jsonify_float(node: float) -> float | str:
180+
"""Narrow a float to something json can encode.
181+
182+
``.inf``/``.nan`` are valid YAML with no JSON encoding, so they render back
183+
to their YAML source form — the same treatment a tag gets.
184+
"""
185+
if math.isnan(node):
186+
return ".nan"
187+
if math.isinf(node):
188+
return ".inf" if node > 0 else "-.inf"
189+
return float(node)
190+
191+
192+
def yaml_jsonify(node: Any) -> Any:
193+
"""Convert a round-trip node into JSON-serializable plain Python.
194+
195+
An HA tag is rendered back to its SOURCE form (``!secret api_key``), never
196+
resolved: the value behind a ``!secret`` lives in secrets.yaml and is not
197+
looked up here, so a parsed view carries no plaintext-secret surface — the
198+
same property the round-trip text view has. Lives here because this module
199+
owns ``_TaggedScalar`` and the tag registry.
200+
201+
ruamel's scalar types subclass the builtins (``ScalarInt``/``ScalarFloat``/
202+
``ScalarString``), so they are narrowed to the plain type; timestamps
203+
(``!!timestamp``) come back as ``date``/``datetime``, which json cannot
204+
encode, and become ISO strings. Non-finite floats (``.inf``/``.nan``) have
205+
no JSON encoding either, so they render back to their YAML source form —
206+
the same treatment a tag gets.
207+
"""
208+
if isinstance(node, _TaggedScalar):
209+
return f"{node.tag} {node.value}".strip()
210+
if isinstance(node, dict):
211+
return {str(key): yaml_jsonify(value) for key, value in node.items()}
212+
if isinstance(node, (list, tuple)):
213+
return [yaml_jsonify(item) for item in node]
214+
# Both branches must precede int: plain bool subclasses int, and a bool
215+
# carrying an anchor loads as ruamel's ScalarBoolean, which subclasses int
216+
# WITHOUT subclassing bool — so an `enabled: &flag true` would otherwise
217+
# serialize as 1.
218+
if node is None or isinstance(node, bool):
219+
return node
220+
if isinstance(node, ScalarBoolean):
221+
return bool(node)
222+
if isinstance(node, int):
223+
return int(node)
224+
if isinstance(node, float):
225+
return _jsonify_float(node)
226+
if isinstance(node, str):
227+
return str(node)
228+
if isinstance(node, (datetime, date)):
229+
return node.isoformat()
230+
return str(node)

src/ha_mcp/settings_ui/_tools_meta.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ class ToolStub(TypedDict):
8989
# won't appear in local_provider._list_tools(), so we inject stub entries
9090
# into the settings UI so users discover the tool exists and how to enable
9191
# it. Keep this dict in sync with the ``"beta"`` tag added to each tool's
92-
# source file (tools_yaml_config.py, tools_filesystem.py, tools_mcp_component.py,
93-
# tools_code.py) — a future rename or removal needs to land in both places.
92+
# source file (tools_yaml_config.py, tools_yaml_read.py, tools_filesystem.py,
93+
# tools_mcp_component.py, tools_code.py) — a future rename or removal needs to
94+
# land in both places.
9495
FEATURE_GATED_TOOLS: dict[str, ToolStub] = {
9596
"ha_config_set_yaml": {
9697
"title": "Set YAML Config",
@@ -99,6 +100,13 @@ class ToolStub(TypedDict):
99100
"disabled_by": "enable_yaml_config_editing",
100101
"destructiveHint": True,
101102
},
103+
"ha_config_get_yaml": {
104+
"title": "Read YAML Config Fragment",
105+
"primary_tag": "System",
106+
"description": "Read the YAML fragment under a key in a config file, or find which file defines it.",
107+
"disabled_by": "enable_filesystem_tools",
108+
"readOnlyHint": True,
109+
},
102110
"ha_manage_custom_tool": {
103111
"title": "Custom Tool",
104112
"primary_tag": "System",

src/ha_mcp/tools/tools_filesystem.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,14 @@
7474
# ENABLE_YAML_EDIT_CONFIRM, default on); a <0.11.0 component's strict
7575
# (PREVENT_EXTRA) schema rejects the unknown arg with a raw voluptuous
7676
# error — the gate surfaces an actionable "update" prompt instead.
77-
MIN_COMPONENT_VERSION = "0.11.0"
77+
# 1.1.0: the YAML fragment read (#1788) needs two component behaviours that a
78+
# <1.1.0 component reports no differently from a missing key. ``list_files``
79+
# only now honours the configured packages folder, so ha_config_get_yaml's
80+
# glob/discovery would otherwise come back "Path not allowed" instead of
81+
# enumerating packages; and ``include_parsed`` on ``read_file`` is a new arg
82+
# that an older strict (PREVENT_EXTRA) schema rejects with a raw voluptuous
83+
# error. The gate turns both into the actionable "update" prompt.
84+
MIN_COMPONENT_VERSION = "1.1.0"
7885

7986

8087
def _version_tuple(version: str) -> tuple[int, ...]:
@@ -511,6 +518,19 @@ async def ha_read_file(
511518
),
512519
),
513520
] = None,
521+
yaml_path: Annotated[
522+
str | None,
523+
Field(
524+
default=None,
525+
description=(
526+
"Dotted YAML key path (e.g. 'alert2', 'mqtt.sensor'). When "
527+
"set, the response also carries 'subtree': the round-trip "
528+
"text of just that key's value. To look a key up across "
529+
"packages/*.yaml, or to get it as structured data, use "
530+
"ha_config_get_yaml instead."
531+
),
532+
),
533+
] = None,
514534
) -> dict[str, Any]:
515535
"""Read a file from the Home Assistant config directory.
516536
@@ -541,6 +561,9 @@ async def ha_read_file(
541561
- size: File size in bytes
542562
- modified: Last modification timestamp
543563
- path: The file path that was read
564+
- subtree: Round-trip text of the `yaml_path` key, when that arg is set
565+
(null when the key is absent). Comments and HA tags (`!secret`,
566+
`!include`) survive as written — a `!secret` is never resolved.
544567
545568
**Example:**
546569
```python
@@ -549,6 +572,9 @@ async def ha_read_file(
549572
550573
# Read last 100 lines of log
551574
result = ha_read_file(path="home-assistant.log", tail_lines=100)
575+
576+
# Read just the alert2 block out of a package file
577+
result = ha_read_file(path="packages/alert2.yaml", yaml_path="alert2")
552578
```
553579
"""
554580
try:
@@ -559,6 +585,8 @@ async def ha_read_file(
559585
service_data: dict[str, Any] = {"path": path}
560586
if tail_lines is not None:
561587
service_data["tail_lines"] = tail_lines
588+
if yaml_path is not None:
589+
service_data["yaml_path"] = yaml_path
562590

563591
# Call the custom component service
564592
result = await call_mcp_tools_service(

0 commit comments

Comments
 (0)