Skip to content

Commit 96bbb3d

Browse files
Patch76kingpanther13claude
authored
feat: let operators extend the YAML write allowlist behind a deny floor (#1997)
* feat: let operators extend the YAML write allowlist behind a deny floor ha_config_set_yaml could only write the hardcoded ALLOWED_YAML_KEYS, so a YAML-first integration that is valid on one install (alert2 in the filed case) was unreachable unless its key was added upstream for everyone. Adds an operator setting, "Extra YAML write keys" (HA_MCP_EXTRA_YAML_KEYS), holding a comma-separated key list that widens the allowlist for that install only. Because the setting hands allowlist control to the operator, it ships together with the floor that bounds it: YAML_KEY_DENYLIST (homeassistant, http, frontend) is checked before every single-key accept branch and is not operator-extendable. Those three redefine Home Assistant's own trust boundary rather than merely being powerful: auth_providers / auth_mfa_modules and the packages root under homeassistant:, trusted_proxies / cors_allowed_origins / ip_ban_enabled under http:, and extra_module_url under frontend:, which loads JavaScript into the authenticated dashboard. Keys such as command_line and shell_command stay allowed - that surface is already accepted today. Enforcement stays in the custom component, the layer that authorizes the write; the server passes the operator's set on the wire as extra_allowed_keys and does not mirror the denylist, so there is one copy to keep correct. Notes on the edges: - extra_allowed_keys is only sent when the operator configured keys, so the strict service schema of an older component is never handed an unknown field under default configuration. When keys ARE configured and the component predates them, a feature-scoped guard returns an actionable update prompt. MIN_COMPONENT_VERSION is deliberately not bumped: an operator who does not use this must not be forced to update. - The backup restore path passes the same set. A write allowed only by the extra keys is auto-snapshotted, so omitting them there would have made that snapshot unrestorable. - The parser lives in config.py rather than beside the YAML tool so backup_manager does not gain an import edge into tools_*. - The setting is registered in ADVANCED_SETTINGS_FIELDS (new beta_yamlkeys section) and renders as a text row nested under "Enable YAML config editing", reusing the code-mode sub-row renderer, which is generalized here rather than duplicated. Component 1.2.3 -> 1.2.4 for the new schema field. Closes #1887 Adversarial review pass folded in before first push: - the version gate now reads the component version from the REST bootstrap cache instead of the WebSocket capability handshake, which returns None for a transport blip as well as for an old component and would have reported a momentary socket drop as "component too old" while blocking writes of ordinary built-in keys - the restore path carries the same gate, so an old component fails there with the actionable prompt rather than an opaque schema rejection - lovelace joins the deny floor: its resources option loads JS modules into the authenticated dashboard under resource_mode yaml, the same primitive frontend is denied for. Only the bare key; lovelace.dashboards.<url_path> is a separate validated shape and stays available - the settings read drops its getattr default so a future rename raises instead of silently reporting "operator configured nothing" Test-coverage pass folded in after that: three links in the chain were reachable-but-unpinned - deleting the version-cache write, the handler's forwarding of the caller's extra keys, or the schema entry itself each left the whole suite green while the feature was dead or the call rejected wholesale. Each now has a test that goes red when it is removed, the version gate via the real REST bootstrap rather than a hand-seeded cache. Documents the setting in docs/beta.md, where the tool safety model already lives. * fix: heal the extra-YAML-keys gate after a component update Three follow-ups on the first round of review and CI. The version gate could latch a stale answer. The caller-token cache is keyed by the long-lived REST client and survives a Home Assistant restart, so once this process had bootstrapped against an older component, updating that component never refreshed the reported version: every extra-key write kept failing, and the remediation the error itself prints ("update, then restart Home Assistant") could not take effect without also restarting ha-mcp. The gate now re-bootstraps once before blocking, so the update heals it on the next call. Only on the failure path, so a satisfied gate costs nothing. The e2e security test asserted the generic allowlist message for `homeassistant`, which now takes the deny-floor branch instead. The probe moves to a genuinely unknown key so it keeps testing the generic path, and a new case covers the floor's own message for all four denied keys. This was the single cause behind every red E2E lane, HAOS included. The new suggestion string was two implicitly concatenated literals inside a list, which reads as a missing comma and tripped the CodeQL quality gate. Parenthesised so the intent is explicit. * fix: keep packages-only keys out of configuration.yaml under extra keys The extra-key setting is documented as additive on top of ALLOWED_YAML_KEYS, but its branch sat above the packages-only rejection, so it was additive on top of ALLOWED_YAML_KEYS | PACKAGES_ONLY_YAML_KEYS instead. Listing `automation` in the setting made it writable in configuration.yaml, where it has never been writable. That routes around two things at once. The storage-mode/YAML-mode collision guarantee only holds while automation, script and scene stay confined to packages/*.yaml. And the per-key toggles that govern them are checked only for package targets, on both the wrapper and the component side, so a configuration.yaml write was not covered by the toggle an operator would expect to be in charge. Nobody designed that behaviour and the comment above the constant described the opposite, so this restores what the comment already claimed rather than narrowing anything: those keys still reach packages/*.yaml through their own branch and their own toggle, and configuration.yaml still answers with the storage-mode advisory. Pinned in both directions at unit level and, since the earlier round showed the e2e surface can drift unnoticed, in the e2e security suite too. * docs: point two stale test references at the tests that actually exist Both comments name a test as the thing that pins an invariant, and both send the reader somewhere the test is not: - tools_yaml_config.py cited the flag-map parity test in test_yaml_config_tool.py; it lives in test_yaml_dashboards.py. - const.py cited a TestManifestVersionParity class; the manifest/constant parity assertion is TestInfo::test_manifest_version_parity in test_component_ws_search.py. Comment-only. Both files are already touched by this PR, and a pointer that resolves to nothing is worth less than no pointer at all. * fix: floor extra-YAML-keys at pending component 1.2.3 instead of opening 1.2.4 1.2.2 is the released stable and 1.2.3 is the unshipped pending version that becomes the next stable, so the first 1.2.3 build anyone receives already carries the extra_allowed_keys service field. Flooring the feature at 1.2.3 is therefore safe, and it keeps the component in step with the release cycle instead of skipping 1.2.3 as a stable number. Reverts the manifest/COMPONENT_VERSION bump to 1.2.3, sets MIN_COMPONENT_VERSION_EXTRA_YAML_KEYS = "1.2.3", and realigns everything that rides that number: the manifest-parity and component-version asserts, the gate tests' old/current pairs (old build 1.2.2, current 1.2.3, floor 1.2.3 in the error), docs/beta.md, the three locales, and the settings.js help text. Also renames a stale renderCodeModeSubRows comment reference in settings.js. * test: cover a successful operator-extra-key write against the real component Every rejection path is e2e-verified, but the feature's actual capability, a non-built-in key writing successfully, was asserted only against a stubbed dispatch client with voluptuous mocked, plus a source guard on the schema line. A wire or schema interaction bug on the success path would pass the whole suite and surface only on a live install. Adds test_extra_key_write_succeeds_against_real_component, mirroring test_add_knx_to_package_file: it writes a key (alert2) that is reachable only through the operator extra-keys setting and asserts success plus post_action=restart_required. To give the write a key, HA_MCP_EXTRA_YAML_KEYS is wired into the in-process/container server's boot env and the embedded server's feature_flags.json override (read by _apply_advanced_overrides). The inaddon HAOS backend has no Supervisor option for this setting, which is a web-UI plus env-var setting by design, so it boots without the key and the test skips there rather than assert a capability it was never given. * docs: correct the extra-keys denylist-drop rationale and a stale JS fn ref _caller_extra_allowed_keys said a caller sending a denied key gets the same "not in the allowed list" answer as before. That is inaccurate: a direct write to a denied key always takes the categorical floor message, because _parse_and_validate_yaml_path checks the denylist first, before any allow-set. The drop's real job is keeping denied keys out of the allowed listing shown in the generic rejection for some other invalid key. Rewords the docstring to match the behavior its own test (test_denylist_key_rejected_even_when_extra_allowed) already proves. Also updates two comments that still named renderCodeModeSubRows after it was renamed to renderAdvancedSubRows. * fix: restore the extra-YAML-keys floor to 1.2.4 now that 1.2.3 has shipped 1.2.3 released as a stable component on release day carrying no extra_allowed_keys field, since this PR is still unmerged, and 1.2.3-dev builds were already public without it. Flooring the feature at 1.2.3 would repeat the #1946 trap: an operator on a shipped 1.2.3 who sets extra keys would clear the version gate and then hit the component's opaque schema rejection. Bump the component to 1.2.4 and floor the feature there, so 1.2.4 is the first version that actually carries the field. Restores manifest / COMPONENT_VERSION to 1.2.4, MIN_COMPONENT_VERSION_EXTRA_YAML_KEYS to 1.2.4, and the numbers that ride it: the manifest-parity and component-version asserts, the gate tests' old/current pairs (old build 1.2.3, current 1.2.4, floor 1.2.4 in the error message), docs/beta.md, the three locales, and the settings.js help text. Keeps the renderAdvancedSubRows comment rename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: store extra YAML write keys in the component, enforced there (#1887) Add a component-owned extra-keys store (ha_mcp_tools_extra_yaml_keys), mirroring the allowed-paths store: loaded into hass.data at setup, updated live by a new set_extra_yaml_keys service, and read at enforcement. The edit_yaml_config handler now unions the per-call wire keys with the stored keys, so a key configured on the component takes effect without the server having to send it. A matching get_extra_yaml_keys service exposes the store (admin + caller-token gated) so the ha-mcp server and the integration's own options flow can read it. The store can never widen the deny floor: YAML_KEY_DENYLIST members are stripped on save and re-validated on load, and _parse_and_validate_yaml_path re-checks the floor at enforcement regardless. Removing the store's denylist filter turns TestNormalizeExtraYamlKeys and TestLoadExtraYamlKeys red (verified by inject-and-revert). This is the component half of surfacing the setting in the integration UI; the options-flow editor and the server-side union follow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: edit the file dirs and extra YAML keys from the integration UI (#1887) The tools-entry options flow ("HA-MCP File & YAML Tools") is no longer a "nothing to configure here yet" placeholder: it now edits the extra file-access directories and the extra YAML write keys directly, so both are reachable from the integration UI and not only the ha-mcp server's own settings. Both fields read and write the component's own .storage, the same source of truth the server settings UI edits, applied live with no restart. Persistence goes through shared _apply_allowed_paths / _apply_extra_yaml_keys helpers, which the set_allowed_paths / set_extra_yaml_keys services now call too, so the validated normalize-and-hot-swap path has a single copy. The deny floor is unchanged: traversal / out-of-config directories and denylisted keys are dropped on save. Updates strings.json and the en/de translations for the new form fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: union the component extra-keys store into the server allowlist (#1887) The operator can now set extra YAML write keys in the integration UI, where they live in the component's own store. The server's pre-dispatch allowlist check would otherwise reject such a key before the write reached the component, so effective_extra_yaml_write_keys reads that store (via the version-gated get_extra_yaml_keys service) and unions it with the server's own HA_MCP_EXTRA_YAML_KEYS. Both the write path (ha_config_set_yaml) and the backup restore path use the union, and the version gate and the wire send follow the effective set. On a component too old to expose the service, or any read failure, the union falls back to the server setting alone and assert_extra_yaml_keys_supported turns a real mismatch into an actionable prompt. The store is read per write, not cached, since the options flow changes it at runtime. Removing the store union turns the new TestEffectiveExtraYamlWriteKeys union cases red (verified by inject-and-revert). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(beta): note extra YAML write keys are editable in the integration UI (#1887) The setting can now be configured from the "HA-MCP File & YAML Tools" integration options in addition to the server settings UI and the env var; the two sets are unioned. Documented alongside the existing deny-floor note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: e2e coverage for extra YAML keys set via the component store (#1887) The store path (integration-UI setting via set_extra_yaml_keys) had only mocked-Store unit tests plus source-string asserts; the sole real-component e2e success test seeds the key via HA_MCP_EXTRA_YAML_KEYS, never through the store. Add a cross-lane e2e that configures a key solely through the real set_extra_yaml_keys service (env absent), proves it writes live, and proves the deny floor drops a denylisted key at save and still refuses its write. --------- Co-authored-by: kingpanther13 <25392815+kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9548581 commit 96bbb3d

26 files changed

Lines changed: 2021 additions & 80 deletions

custom_components/ha_mcp_tools/__init__.py

Lines changed: 351 additions & 19 deletions
Large diffs are not rendered by default.

custom_components/ha_mcp_tools/config_flow.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -241,29 +241,70 @@ async def async_step_server(
241241

242242

243243
class HaMcpToolsInfoOptionsFlow(OptionsFlow):
244-
"""Options flow for the tools entry: a light informational form.
244+
"""Options flow for the tools entry: edit the privileged services' config.
245245
246-
The tools services entry has nothing to configure yet, but aborting the
247-
Configure dialog reads as an error. Show an empty-schema form that explains
248-
what the entry provides instead; submitting persists an empty options
249-
payload.
246+
Surfaces the two operator-tunable sets the file/YAML tools honour - the
247+
extra read/write directories and the extra top-level YAML write keys - so
248+
they are reachable from the integration UI, not only the ha-mcp server's own
249+
settings. Both live in the component's own .storage (get/set_allowed_paths
250+
and get/set_extra_yaml_keys), so this screen and the server settings UI edit
251+
the same source of truth, applied live with no restart. The deny floor is
252+
non-overridable: traversal / out-of-config directories and denylisted keys
253+
are dropped on save.
250254
251255
The form uses the ``tools_info`` step id, NOT ``init``: the server options
252256
flow already owns ``options.step.init`` in strings.json, so a shared step id
253-
would collide. ``async_step_init`` is the required entry point (it renders
254-
the form); HA routes the form's submit to ``async_step_tools_info``.
257+
would collide. ``async_step_init`` renders the form; HA routes the form's
258+
submit to ``async_step_tools_info``.
255259
"""
256260

261+
def _tools_form_schema(self) -> vol.Schema:
262+
"""Build the form schema with the current stored values as defaults."""
263+
from . import _current_extra_dirs, _current_extra_yaml_keys
264+
265+
current_dirs = _current_extra_dirs(self.hass)
266+
current_keys = _current_extra_yaml_keys(self.hass)
267+
return vol.Schema(
268+
{
269+
vol.Optional("allowed_dirs", default=current_dirs): SelectSelector(
270+
SelectSelectorConfig(
271+
options=current_dirs,
272+
multiple=True,
273+
custom_value=True,
274+
mode=SelectSelectorMode.LIST,
275+
)
276+
),
277+
vol.Optional("extra_yaml_keys", default=current_keys): SelectSelector(
278+
SelectSelectorConfig(
279+
options=current_keys,
280+
multiple=True,
281+
custom_value=True,
282+
mode=SelectSelectorMode.LIST,
283+
)
284+
),
285+
}
286+
)
287+
257288
async def async_step_init(
258289
self, user_input: dict[str, Any] | None = None
259290
) -> ConfigFlowResult:
260-
"""Render the informational form under the ``tools_info`` step id."""
261-
return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({}))
291+
"""Render the editable form under the ``tools_info`` step id."""
292+
return self.async_show_form(
293+
step_id="tools_info", data_schema=self._tools_form_schema()
294+
)
262295

263296
async def async_step_tools_info(
264297
self, user_input: dict[str, Any] | None = None
265298
) -> ConfigFlowResult:
266-
"""Persist an empty options payload once the info form is submitted."""
299+
"""Persist the edited directories and keys once the form is submitted."""
300+
if user_input is None:
301+
return self.async_show_form(
302+
step_id="tools_info", data_schema=self._tools_form_schema()
303+
)
304+
from . import _apply_allowed_paths, _apply_extra_yaml_keys
305+
306+
await _apply_allowed_paths(self.hass, user_input.get("allowed_dirs", []))
307+
await _apply_extra_yaml_keys(self.hass, user_input.get("extra_yaml_keys", []))
267308
return self.async_create_entry(title="", data={})
268309

269310

custom_components/ha_mcp_tools/const.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020

2121
# Component version, kept in lockstep with ``manifest.json``'s ``version``.
2222
# ``ha_mcp_tools/info`` reports this so the server can display/debug the running
23-
# component build; ``TestManifestVersionParity`` pins the two together so a
24-
# manifest bump that forgets this constant (or vice-versa) fails in CI. The
23+
# component build; ``TestInfo::test_manifest_version_parity`` pins the two
24+
# together so a manifest bump that forgets this constant (or vice-versa) fails
25+
# in CI. The
2526
# capability negotiation — not this version — gates each WS command (see
2627
# ``websocket_api.CAPABILITIES``).
2728
COMPONENT_VERSION = "1.2.4"
@@ -96,7 +97,11 @@
9697

9798
# Top-level YAML keys allowed for editing in any allowed file
9899
# (configuration.yaml or packages/*.yaml).
99-
# ONLY keys that have no UI/API alternative belong here.
100+
# The bar is "YAML is a legitimate way to manage this key", not "this key
101+
# has no UI alternative": template, utility_meter and group do have helper
102+
# equivalents and stay allowed for git-managed YAML configs (the caller
103+
# attaches a routing warning instead – see _HELPER_EQUIVALENT_KEYS in
104+
# src/ha_mcp/tools/tools_yaml_config.py).
100105
# Keys manageable via ha_config_set_helper (input_*, counter, timer, schedule)
101106
# are intentionally excluded. automation/script/scene live in
102107
# PACKAGES_ONLY_YAML_KEYS below — they have storage-mode equivalents
@@ -143,6 +148,51 @@
143148
}
144149
)
145150

151+
# Top-level YAML keys an operator can never unlock (#1887).
152+
# The operator-configurable extra-key list (ha-mcp's "extra YAML write
153+
# keys" setting) is additive on top of ALLOWED_YAML_KEYS, so this floor
154+
# is what keeps that setting from reaching HA's own trust boundary. It is
155+
# checked before every single-key allowlist branch and is deliberately NOT
156+
# operator-extendable – otherwise the same trust question just reopens
157+
# one level up. Scope note: it guards the per-key merge path only.
158+
# ``action="replace_file"`` returns before key validation runs at all, so a
159+
# whole-file rewrite of configuration.yaml can still contain these keys –
160+
# pre-existing behaviour, and the reason this is a floor under the extra-key
161+
# setting rather than a general "these keys are unwritable" guarantee.
162+
#
163+
# The bar is not "powerful": command_line, shell_command and rest are
164+
# already allowed above, so command execution and outbound HTTP are
165+
# accepted surface. The bar is "redefines authentication, escalates the
166+
# write surface itself, or can lock the user out" – unrecoverable in a
167+
# way a broken sensor is not. Verified against home-assistant/core:
168+
# homeassistant: CORE_CONFIG_SCHEMA (homeassistant/core_config.py) takes
169+
# auth_providers / auth_mfa_modules (how the instance authenticates)
170+
# and packages (which folder is loaded as packages – a write here
171+
# would redirect the very surface this feature is bounded by).
172+
# http: takes trusted_proxies + use_x_forwarded_for (a spoofable
173+
# X-Forwarded-For becomes an auth bypass), cors_allowed_origins, and
174+
# ip_ban_enabled / login_attempts_threshold (brute-force protection).
175+
# frontend: takes extra_module_url, JavaScript modules loaded into the
176+
# authenticated dashboard – a stored-XSS foothold with access to the
177+
# instance and its tokens.
178+
# lovelace: takes resources (url + type: module), loaded whenever
179+
# resource_mode resolves to yaml. That is the same JS-into-an-
180+
# authenticated-dashboard primitive as frontend: extra_module_url, so
181+
# denying one while allowing the other would be a floor contradicting
182+
# its own rationale. Only the bare key is denied; the validated
183+
# lovelace.dashboards.<url_path> shape is a different branch and stays
184+
# available for YAML-mode dashboard management.
185+
# auth and api are absent on purpose: both have an empty CONFIG_SCHEMA in
186+
# core, so there is no sub-key to restrict.
187+
YAML_KEY_DENYLIST = frozenset(
188+
{
189+
"homeassistant",
190+
"http",
191+
"frontend",
192+
"lovelace",
193+
}
194+
)
195+
146196
# Post-edit action required for each YAML key.
147197
# template, mqtt, group, automation, script, and scene have first-party
148198
# reload services in HA core. All others require a full HA restart.

custom_components/ha_mcp_tools/services.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,33 @@ set_allowed_paths:
210210
example: '["pyscript", "python_scripts"]'
211211
selector:
212212
object:
213+
214+
get_extra_yaml_keys:
215+
name: Get Extra YAML Write Keys (Internal)
216+
description: >-
217+
Internal service used by the ha-mcp server and the integration's own
218+
options flow. Returns the component-configured extra top-level YAML write
219+
keys and the non-overridable deny floor. Restricted to the ha-mcp server
220+
(requires the `_ha_mcp_token`) and admin auth. Not intended for direct
221+
invocation from automations or scripts.
222+
fields: {}
223+
224+
set_extra_yaml_keys:
225+
name: Set Extra YAML Write Keys (Internal)
226+
description: >-
227+
Internal service used by the ha-mcp server settings UI and the
228+
integration's own options flow. Replaces the component-configured extra
229+
top-level keys that ha_config_set_yaml may write in addition to the
230+
built-in ones. Blank entries and keys on the non-overridable deny floor
231+
(e.g. homeassistant, http, frontend, lovelace) are dropped. Restricted to
232+
the ha-mcp server (requires the `_ha_mcp_token`) and admin auth.
233+
fields:
234+
keys:
235+
name: Keys
236+
description: >-
237+
Top-level YAML keys, each additionally writable by ha_config_set_yaml
238+
on this install (e.g. "alert2"). Replaces the current list.
239+
required: false
240+
example: '["alert2"]'
241+
selector:
242+
object:

custom_components/ha_mcp_tools/strings.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@
2727
"step": {
2828
"tools_info": {
2929
"title": "HA-MCP File & YAML Tools",
30-
"description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release."
30+
"description": "Configure the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. These settings also appear in the ha-mcp server's own settings UI and apply live, with no restart. A non-overridable deny floor still blocks sensitive paths (e.g. .storage) and trust-boundary keys (homeassistant, http, frontend, lovelace).",
31+
"data": {
32+
"allowed_dirs": "Extra file directories",
33+
"extra_yaml_keys": "Extra YAML write keys"
34+
},
35+
"data_description": {
36+
"allowed_dirs": "Directories relative to the config directory, each granted read and write (e.g. pyscript). Traversal and out-of-config entries are dropped.",
37+
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
38+
}
3139
},
3240
"init": {
3341
"title": "HA-MCP Server",

custom_components/ha_mcp_tools/translations/de.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@
2727
"step": {
2828
"tools_info": {
2929
"title": "HA-MCP File & YAML Tools",
30-
"description": "Dieser Eintrag stellt die privilegierten Datei- und YAML-Bearbeitungsdienste bereit, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Hier gibt es noch nichts zu konfigurieren. Welche Verzeichnisse die Datei-Tools nutzen dürfen, wird in den eigenen Einstellungen des ha-mcp-Servers (Settings UI / allowed-paths) verwaltet, nicht hier. Pro-Eintrag-Optionen könnten in einem zukünftigen Release auf diesem Bildschirm erscheinen."
30+
"description": "Konfiguriere die privilegierten Datei- und YAML-Bearbeitungsdienste, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Diese Einstellungen erscheinen auch in der eigenen Einstellungs-UI des ha-mcp-Servers und werden live wirksam, ohne Neustart. Eine nicht überschreibbare Deny-Grenze blockiert weiterhin sensible Pfade (z. B. .storage) und Vertrauensgrenzen-Keys (homeassistant, http, frontend, lovelace).",
31+
"data": {
32+
"allowed_dirs": "Zusätzliche Datei-Verzeichnisse",
33+
"extra_yaml_keys": "Zusätzliche YAML-Schreib-Keys"
34+
},
35+
"data_description": {
36+
"allowed_dirs": "Verzeichnisse relativ zum Konfigurationsverzeichnis, jeweils mit Lese- und Schreibzugriff (z. B. pyscript). Traversal- und Einträge außerhalb des Konfig-Verzeichnisses werden verworfen.",
37+
"extra_yaml_keys": "Top-Level-Keys, die ha_config_set_yaml zusätzlich zu den eingebauten schreiben darf, für YAML-first-Integrationen auf dieser Installation (z. B. alert2). Vertrauensgrenzen-Keys werden verworfen."
38+
}
3139
},
3240
"init": {
3341
"title": "HA-MCP Server",

custom_components/ha_mcp_tools/translations/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@
2727
"step": {
2828
"tools_info": {
2929
"title": "HA-MCP File & YAML Tools",
30-
"description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release."
30+
"description": "Configure the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. These settings also appear in the ha-mcp server's own settings UI and apply live, with no restart. A non-overridable deny floor still blocks sensitive paths (e.g. .storage) and trust-boundary keys (homeassistant, http, frontend, lovelace).",
31+
"data": {
32+
"allowed_dirs": "Extra file directories",
33+
"extra_yaml_keys": "Extra YAML write keys"
34+
},
35+
"data_description": {
36+
"allowed_dirs": "Directories relative to the config directory, each granted read and write (e.g. pyscript). Traversal and out-of-config entries are dropped.",
37+
"extra_yaml_keys": "Top-level keys ha_config_set_yaml may write in addition to the built-in ones, for YAML-first integrations on this install (e.g. alert2). Trust-boundary keys are dropped."
38+
}
3139
},
3240
"init": {
3341
"title": "HA-MCP Server",

docs/beta.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ This tool edits `configuration.yaml` and package files directly, bypassing Home
6161

6262
**`command_line:` entries execute shell commands.** The allowlist includes `command_line:` for legitimate use cases, but an LLM could inadvertently create a sensor with a command that reads sensitive files or modifies the system.
6363

64+
**The key allowlist is extensible per install.** Some integrations are legitimately YAML-first and too install-specific to hardcode globally. **Extra YAML write keys** (the "HA-MCP File & YAML Tools" integration options, the web Settings UI nested under YAML config editing, or `HA_MCP_EXTRA_YAML_KEYS`) takes a comma-separated list of extra top-level keys `ha_config_set_yaml` may write on this install, e.g. `alert2`. The integration-options set and the server setting are unioned, so a key configured in either place takes effect. Everything else is unchanged: the file allowlist, the confirm flow, the backup, and the post-edit config check all still apply, and an extra key with no reload service gets the conservative full-restart path. A small set of keys can never be added to this setting, because they redefine Home Assistant's own trust boundary rather than one integration's config: `homeassistant:` (auth providers, and the `packages:` root that bounds this very surface), `http:` (trusted proxies, CORS, IP-ban), `frontend:` and `lovelace:` (both load JavaScript modules into the authenticated dashboard). Those stay refused with an explicit message. This bounds the per-key edit path; `action="replace_file"` rewrites a whole file and has never been key-validated. Requires custom component 1.2.4 or newer.
65+
6466
**Recovery requires filesystem access.** If an edit causes HA to enter recovery mode (e.g., a bad `!include` reference), `ha_config_set_yaml` cannot fix its own damage since the custom component doesn't load in recovery mode. Recovery requires SSH, the File Editor add-on, or `docker exec`.
6567

6668
**Per-edit backups are restorable via `ha_manage_backup`.** Per-edit auto-backups are written to `.ha_mcp_tools_backups/` (at the Home Assistant config root) and can be listed, viewed, restored, and deleted with `ha_manage_backup(scope="edits", ...)`. Full HA snapshot tarballs are separate — create, list, and restore them with `scope="snapshot"`.

src/ha_mcp/backup_manager.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2128,24 +2128,43 @@ async def _restore_yaml(client: Any, entity_id: str, config: Any) -> Any:
21282128
``edit_yaml_config`` is the only write path that reaches HA config
21292129
files (``write_file`` rejects them), so YAML restore goes through it
21302130
with ``action="replace"``.
2131+
2132+
The operator's extra write keys (#1887) must ride along: the write that
2133+
produced this snapshot was allowed only because of them, so omitting
2134+
them here would make an auto-captured backup unrestorable. Note the
2135+
asymmetry with ``disabled_packages_keys``, whose default is permissive
2136+
and can therefore be left off.
21312137
"""
2132-
from .tools.tools_filesystem import call_mcp_tools_service
2138+
from .config import get_global_settings
2139+
from .tools.tools_filesystem import (
2140+
assert_extra_yaml_keys_supported,
2141+
call_mcp_tools_service,
2142+
effective_extra_yaml_write_keys,
2143+
)
21332144
from .tools.util_helpers import unwrap_service_response
21342145

21352146
# Split on the LAST "::" (see _fetch_yaml) so an exotic file path
21362147
# containing "::" still restores to the right file and key.
21372148
file, sep, yaml_path = entity_id.rpartition("::")
21382149
if not sep or not file or not yaml_path:
21392150
raise ValueError(f"Invalid yaml snapshot target: {entity_id!r}")
2151+
service_data: dict[str, Any] = {
2152+
"file": file,
2153+
"action": "replace",
2154+
"yaml_path": yaml_path,
2155+
"content": str(config),
2156+
}
2157+
extra_keys = await effective_extra_yaml_write_keys(client, get_global_settings())
2158+
if extra_keys:
2159+
# Same version gate as the write path: without it a component that
2160+
# predates the field rejects the whole restore call over an option
2161+
# the snapshot being restored may not even use.
2162+
await assert_extra_yaml_keys_supported(client, extra_keys)
2163+
service_data["extra_allowed_keys"] = extra_keys
21402164
result = await call_mcp_tools_service(
21412165
client,
21422166
"edit_yaml_config",
2143-
{
2144-
"file": file,
2145-
"action": "replace",
2146-
"yaml_path": yaml_path,
2147-
"content": str(config),
2148-
},
2167+
service_data,
21492168
)
21502169
if isinstance(result, dict):
21512170
result = unwrap_service_response(result)

0 commit comments

Comments
 (0)