Skip to content

Commit 738e3b9

Browse files
kingpanther13claude
andcommitted
chore(addon): bump ha_mcp_tools to 0.5.1 + spell out tool names in rejects
Folds together three small things that share the same custom- component file: * Manifest bump 0.5.0 → 0.5.1 (was 0.6.0 in the earlier push; this is a small patch-shape change, not new capability — the PACKAGES_ONLY_YAML_KEYS branch is a routing addition, no new surface). Pattern mirrors homeassistant-ai#1459's bump on the same file. * Patch76 follow-up #1 (PR comment 2026-05-28): spell each storage-mode tool out individually in the rejection guidance instead of the compact slash-form ``ha_config_set_automation/script/scene``. An agent reading the rejection at call time would otherwise parse that as one malformed tool name and fail to route. Locations: * ``__init__.py`` reject message * ``tools_yaml_config.py`` ``yaml_path`` parameter description * Patch76 follow-up #2: ``TestHandleEditYamlConfigPathTraversal`` pins the layering of ``os.path.normpath`` before the ``fnmatch`` package check so a crafted ``packages/../configuration.yaml`` cannot smuggle a PACKAGES_ONLY key (``automation``) into ``configuration.yaml``. Belt-and-suspenders against a future refactor reordering those two steps; defense is already correct by construction. Updated tests for the spell-out: * ``test_yaml_config.py`` E2E rejection asserts each tool name individually instead of the combined slash-string. * ``test_yaml_dashboards.py::test_rejects_packages_only_key_in_configuration_yaml`` does the same at the unit layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e94c95d commit 738e3b9

5 files changed

Lines changed: 91 additions & 8 deletions

File tree

custom_components/ha_mcp_tools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -774,7 +774,8 @@ def _parse_and_validate_yaml_path(
774774
f"Key '{yaml_path}' is only allowed in packages/*.yaml "
775775
"files, not in configuration.yaml. Move the edit to a "
776776
"package file (e.g., packages/automations.yaml) or use "
777-
"ha_config_set_automation/script/scene for storage-mode."
777+
"ha_config_set_automation, ha_config_set_script, or "
778+
"ha_config_set_scene for storage-mode."
778779
),
779780
)
780781
allowed = (

custom_components/ha_mcp_tools/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@
88
"iot_class": "local_push",
99
"issue_tracker": "https://github.com/homeassistant-ai/ha-mcp/issues",
1010
"requirements": ["ruamel.yaml>=0.18.0"],
11-
"version": "0.5.0"
11+
"version": "0.5.1"
1212
}

src/ha_mcp/tools/tools_yaml_config.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,10 @@ async def ha_config_set_yaml(
111111
"route. No other multi-segment paths are supported. "
112112
"'automation', 'script', and 'scene' are accepted only when "
113113
"file is under packages/*.yaml; in configuration.yaml use "
114-
"the dedicated ha_config_set_automation/script/scene tools "
115-
"(storage-mode). Not for template sensors or input_* "
116-
"helpers — those have dedicated tools."
114+
"the dedicated storage-mode tools "
115+
"(ha_config_set_automation, ha_config_set_script, "
116+
"ha_config_set_scene). Not for template sensors or "
117+
"input_* helpers — those have dedicated tools."
117118
),
118119
),
119120
],

tests/src/e2e/workflows/filesystem/test_yaml_config.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,8 +488,17 @@ async def test_packages_only_keys_rejected_in_configuration_yaml(
488488
assert "packages/*.yaml" in msg, (
489489
f"{key} error message should mention packages/*.yaml: {data}"
490490
)
491-
assert "ha_config_set_automation/script/scene" in msg, (
492-
f"{key} error should point at storage-mode tools: {data}"
491+
# Spell out each tool name individually — an agent reading
492+
# the rejection would otherwise see the combined slash-form
493+
# as a single (malformed) tool name and fail to route.
494+
assert "ha_config_set_automation" in msg, (
495+
f"{key} error should mention ha_config_set_automation: {data}"
496+
)
497+
assert "ha_config_set_script" in msg, (
498+
f"{key} error should mention ha_config_set_script: {data}"
499+
)
500+
assert "ha_config_set_scene" in msg, (
501+
f"{key} error should mention ha_config_set_scene: {data}"
493502
)
494503
# Rejected calls must not advertise reload metadata.
495504
assert data.get("post_action") is None, (

tests/src/unit/test_yaml_dashboards.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,11 @@ def test_rejects_packages_only_key_in_configuration_yaml(self, parse, key):
221221
_, _, err = parse(key, is_package=False)
222222
assert err is not None
223223
assert "packages/*.yaml" in err
224-
assert "ha_config_set_automation/script/scene" in err
224+
# Spell each tool name out individually — an agent reading the
225+
# rejection sees the combined slash-form as one malformed name.
226+
assert "ha_config_set_automation" in err
227+
assert "ha_config_set_script" in err
228+
assert "ha_config_set_scene" in err
225229

226230
def test_default_is_package_false(self, parse):
227231
# Omitting is_package must behave like is_package=False — no silent
@@ -693,3 +697,71 @@ def test_single_key_post_action_for_shell_command(
693697
)
694698
assert result["success"] is True, result
695699
assert result["post_action"] == "restart_required"
700+
701+
702+
class TestHandleEditYamlConfigPathTraversal:
703+
"""Path-traversal defense composes ``os.path.normpath`` before the
704+
``fnmatch`` package check, so a crafted ``packages/../configuration.yaml``
705+
cannot smuggle a PACKAGES_ONLY key into ``configuration.yaml``."""
706+
707+
@pytest.fixture
708+
def hass(self, tmp_path):
709+
h = MM()
710+
h.config = MM()
711+
h.config.config_dir = str(tmp_path)
712+
h.data = {DOMAIN: {"caller_token": _TEST_CALLER_TOKEN}}
713+
714+
async def _run(fn, *args):
715+
return fn(*args)
716+
717+
h.async_add_executor_job = AsyncMock(side_effect=_run)
718+
h.services = MM()
719+
h.services.async_call = AsyncMock(return_value={"errors": None})
720+
return h
721+
722+
@pytest.fixture
723+
def call_factory(self):
724+
def _make(data):
725+
call = MM()
726+
call.data = {**data, CALLER_TOKEN_FIELD: _TEST_CALLER_TOKEN}
727+
return call
728+
729+
return _make
730+
731+
def _run(self, coro):
732+
return asyncio.run(coro)
733+
734+
def test_packages_dotdot_normalizes_to_configuration_yaml_and_rejects(
735+
self, tmp_path, hass, call_factory
736+
):
737+
"""``packages/../configuration.yaml`` → ``configuration.yaml`` after
738+
normpath, so the ``packages/*.yaml`` fnmatch does not match and
739+
``yaml_path="automation"`` is rejected with the storage-mode pointer.
740+
741+
Pins the layering: normpath at __init__.py:330 must run before
742+
the fnmatch package check at __init__.py:338 so a crafted path
743+
cannot smuggle a PACKAGES_ONLY key into configuration.yaml. The
744+
defense is correct by construction; this is belt-and-suspenders
745+
against a future refactor reordering those two steps."""
746+
from custom_components.ha_mcp_tools import _build_edit_yaml_config_handler
747+
748+
cfg = Path(tmp_path) / "configuration.yaml"
749+
cfg.write_text("")
750+
751+
handler = _build_edit_yaml_config_handler(hass)
752+
result = self._run(
753+
handler(
754+
call_factory(
755+
{
756+
"file": "packages/../configuration.yaml",
757+
"action": "add",
758+
"yaml_path": "automation",
759+
"content": "- id: x\n alias: x\n",
760+
"backup": False,
761+
}
762+
)
763+
)
764+
)
765+
assert result["success"] is False
766+
assert "packages/*.yaml" in result["error"]
767+
assert "ha_config_set_automation" in result["error"]

0 commit comments

Comments
 (0)