Skip to content

Commit 144122a

Browse files
kingpanther13claude
andcommitted
fix(code-mode): address Patch76 round-3 review (H1, M2, M3, L1-L6) + clean uv.lock
Rebased on current upstream/master (was 8 commits behind) and regenerated uv.lock so it adds only ``pydantic-monty==0.0.9`` — the prior lockfile carried 8 unrelated package downgrades that the fresh resolver pass clears (M1). H1 — entity_registry/remove + device_registry/remove_config_entry ----------------------------------------------------------------- The blocklist was using ``config/entity_registry/delete`` and ``config/device_registry/delete`` — neither is a registered HA Core WS command. Verified ha-mcp itself emits the actually-registered names: ``tools_entities.py:1130`` uses ``config/entity_registry/remove`` and ``tools_registry.py:753`` uses ``config/device_registry/remove_config_entry``. A sandbox ``ws_send({"type": "config/entity_registry/remove", ...})`` was slipping past the blocklist and bypassing ``ha_remove_entity``'s wrapping checks. Added both real names to ``_BLOCKED_WS_COMMANDS``. Left the dead ``*_registry/delete`` strings as-is — over-blocking inert names is harmless, under-blocking real ones is the bug. M2 — percent-encoded ``..`` traversal ------------------------------------- The segment-loop check at ``_normalize_endpoint`` rejects literal ``..`` segments but treats ``%2e%2e`` as a normal segment. httpx itself doesn't decode at the transport level, but reverse proxies (nginx with config drift, traefik with custom routers) sometimes decode-then-resolve, which would let ``%2e%2e/auth/providers`` escape ``/api/`` server-side. Each segment is now passed through ``urllib.parse.unquote`` before the ``..`` comparison so encoded forms can't slip past. M3 — naked list returns from ``_extract_tool_result`` ----------------------------------------------------- The basic-types tuple at line 566 didn't include ``list``, so a tool returning a plain ``[{"id": 1}, ...]`` fell into the ToolResult-extraction branch, found no ``.text`` on its dict elements, and got string-repr'd. Sandbox code ended up with ``"[{'id': 1}, ...]"`` instead of an iterable list. Lists are now passed through when they don't look like FastMCP's content-block shape (heuristic: first element has ``.text`` or ``.type`` — the two attributes content blocks always carry). Plain data lists pass straight to the sandbox. L1 — sandbox-side error shape contract documented -------------------------------------------------- Patch76 noted that sandbox code sees two error shapes (``{"error": str}`` from the bridge helpers, ``{"success": False, "error": {"code", "message"}}`` from ``_sandbox_error``) and suggested standardizing on the structured shape. Standardizing that direction would have changed ~15 existing tests that do ``result.get("error", "").lower()`` blindly. The simpler ``{"error": str}`` shape is what most helpers and tests already assume, and the structured form has real value for ``call_tool`` (propagates the underlying tool's ``ErrorCode``). Resolved instead by making the contract explicit in the ``_run_sandboxed_code`` docstring: both shapes are documented, consumers should always probe with ``if "error" in result:`` before indexing. Same UX outcome (consumers know what to expect) without churning the tests. L2 — switched ``%r`` for the justification log line --------------------------------------------------- Removed the ``_log_safe`` helper and inlined ``%r`` (repr()) for the LLM-supplied ``justification`` log line, matching the ``%r`` already used for endpoint/type fields in the audit-log lines. ``%r`` escapes ``\r`` / ``\n`` / ``\t`` as literal ``\r`` / ``\n`` / ``\t`` sequences in formatted output — same log-injection prevention as ``_log_safe`` but consistent with the rest of the file. L4 / L5 / L6 — clarifying comments ---------------------------------- * L4: Note next to the ``_saved_tools_load_failed`` read in ``_save_saved_tools`` that the read is intentionally without ``global`` — Python doesn't require it for read-only access. * L5: One-line clarification in ``_normalize_endpoint``'s docstring that ``@`` later in the path (``events/foo@bar``) is acceptable — only userinfo position (before the first ``/``) is the credential-leaking shape. * L6: Block comment on the ``_saved_tools`` module-level declaration explaining *why* it's at module level (cross-call persistence is the documented contract; per-request scope wouldn't allow ``run_saved`` to see prior ``save_as`` writes), and pointing at ``code_mode_saved_tools_path`` as the persistence boundary. Test expansion — full ``_BLOCKED_WS_COMMANDS`` parametrize ---------------------------------------------------------- ``test_ws_send_blocks_command`` now parametrizes over all 25 entries in ``_BLOCKED_WS_COMMANDS`` (was: 3 hand-picked cases). The "blocklist names a command HA Core doesn't accept" class of bug surfaces in CI now: if the blocklist drops an entry, the corresponding parametrize row fails; if HA Core renames a command, the test fails with the now-stale name still in the parametrize list and a maintainer notices. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80c424a commit 144122a

3 files changed

Lines changed: 204 additions & 129 deletions

File tree

src/ha_mcp/tools/tools_code.py

Lines changed: 70 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import logging
2626
import re
2727
import tempfile
28+
import urllib.parse
2829
from datetime import UTC, datetime
2930
from pathlib import Path
3031
from typing import Any
@@ -39,34 +40,22 @@
3940
logger = logging.getLogger(__name__)
4041

4142
# In-memory cache for saved custom tools, optionally persisted to disk.
43+
#
44+
# Lives at module level deliberately: ``ha_manage_custom_tool`` is a
45+
# stateless MCP tool that needs ``run_saved`` / ``list_saved`` to see
46+
# entries written by earlier ``save_as`` calls in the same process.
47+
# Hydrated from ``settings.code_mode_saved_tools_path`` on
48+
# ``register_code_tools`` startup (one-time) and persisted on every
49+
# subsequent ``save_as`` / ``delete_saved_tool``. Per-call request
50+
# scope wouldn't work because ``run_saved`` would never see prior
51+
# saves; ``code_mode_saved_tools_path`` is the documented persistence
52+
# boundary.
53+
#
4254
# WARNING: This is shared across all clients in the same server process.
4355
# In multi-user modes (OAuth, HTTP), one user's saved tools are visible to
4456
# all other users. Scope to per-session/user before multi-user support.
45-
# When ``settings.code_mode_saved_tools_path`` is set, the cache is hydrated
46-
# from disk on registration and persisted on every save_as / delete.
4757
_saved_tools: dict[str, dict[str, str]] = {}
4858

49-
# Translation table for ``_log_safe`` — replaces CR / LF / TAB with a single
50-
# space so an LLM-controlled string interpolated into a log line via ``%s``
51-
# can't manufacture extra log records.
52-
_LOG_CONTROL_CHARS_MAP = str.maketrans({"\r": " ", "\n": " ", "\t": " "})
53-
54-
55-
def _log_safe(value: Any, max_len: int = 200) -> str:
56-
"""Return ``value`` flattened to a single line, safe for ``%s`` log interpolation.
57-
58-
Replaces ``\\r`` / ``\\n`` / ``\\t`` with spaces and truncates to
59-
``max_len`` characters. Used on user-controlled strings (``justification``,
60-
saved-tool ``name``, etc.) before they reach ``logger.info(..., %s, ...)``
61-
so a crafted input like ``"real reason\\nFAKE_CRITICAL: thing crashed"``
62-
cannot inject a second log line.
63-
"""
64-
text = str(value)
65-
if len(text) > max_len:
66-
text = text[:max_len]
67-
return text.translate(_LOG_CONTROL_CHARS_MAP)
68-
69-
7059
# Tools that sandbox code must not call. Includes ``ha_manage_custom_tool``
7160
# itself (prevents recursive self-invocation) plus the four synthetics that
7261
# the categorized-search transform exposes when ``ENABLE_TOOL_SEARCH=true``
@@ -179,9 +168,18 @@ def _log_safe(value: Any, max_len: int = 200) -> str:
179168
"config/device_registry/delete",
180169
"config/device_registry/disable",
181170
"config/device_registry/update",
171+
# Device registry deletion is registered as ``remove_config_entry`` on
172+
# HA Core, not ``delete`` — see ``tools_registry.py:753`` for the
173+
# actually-emitted command. ``ha_remove_device`` wraps it; raw
174+
# ``ws_send`` would skip those checks.
175+
"config/device_registry/remove_config_entry",
182176
"config/entity_registry/delete",
183177
"config/entity_registry/disable",
184178
"config/entity_registry/update",
179+
# Entity registry deletion is registered as ``remove`` on HA Core,
180+
# not ``delete`` — see ``tools_entities.py:1130`` for the
181+
# actually-emitted command. ``ha_remove_entity`` wraps it.
182+
"config/entity_registry/remove",
185183
# Floor / label / category registries follow the same rationale as
186184
# area / device / entity above: each has a wrapping MCP tool
187185
# (``ha_config_set_floor``, ``ha_config_set_label``,
@@ -497,6 +495,8 @@ def _save_saved_tools(
497495
"""
498496
if not path_str:
499497
return True
498+
# Read-only access to the module-level flag; ``global`` declaration
499+
# only needed at the set sites in ``_load_saved_tools``.
500500
if _saved_tools_load_failed:
501501
logger.warning(
502502
"Skipping persist to %s because the prior load failed; "
@@ -556,6 +556,18 @@ def _extract_tool_result(result: Any) -> Any:
556556
if isinstance(result, (str, int, float, bool, type(None), dict)):
557557
return result
558558

559+
# ``list`` is also a basic type Monty handles, but a list might also
560+
# be FastMCP's "list of content objects" shape (each element having
561+
# a ``.text`` or being a content-block ``dict``). Distinguish: if the
562+
# first element looks like a content object, treat as a ToolResult
563+
# payload; otherwise pass through as a normal list of basic values.
564+
if isinstance(result, list):
565+
looks_like_content = bool(result) and (
566+
hasattr(result[0], "text") or hasattr(result[0], "type")
567+
)
568+
if not looks_like_content:
569+
return result
570+
559571
# ToolResult or similar: extract content list
560572
content = None
561573
if hasattr(result, "content"):
@@ -615,6 +627,18 @@ async def _run_sandboxed_code(
615627
- api_post(endpoint, data) — POST request to HA REST API
616628
- ws_send(message) — send a HA WebSocket command and return its result
617629
- call_tool(name, args) — call a registered MCP tool (for existing tools)
630+
631+
**Error shape contract.** All bridge functions return a dict with an
632+
``"error"`` key on failure; the value may be either a plain string
633+
(``api_get`` / ``api_post`` / ``ws_send`` / ``delete_saved_tool`` —
634+
transport-level failures, validation rejections) or a structured
635+
sub-dict ``{"code": "<ErrorCode>", "message": "<text>"}`` from
636+
``_sandbox_error`` (``call_tool`` — propagates the underlying tool's
637+
``ErrorCode`` so sandbox code can branch on category). The structured
638+
form also includes ``"success": False`` at the top level. **Consumers
639+
should always probe with ``if "error" in result:``** — never
640+
``result["error"].lower()`` blindly, because the value isn't always a
641+
string.
618642
"""
619643
call_count = 0
620644

@@ -641,13 +665,21 @@ def _normalize_endpoint(endpoint: Any) -> str:
641665
or ``@`` before the first ``/`` (userinfo). Without this httpx
642666
will dispatch the request to the absolute host *with the HA
643667
bearer token still attached*, leaking credentials.
668+
669+
Note: ``@`` later in the path (``events/foo@bar``) is fine —
670+
only userinfo position (before the first ``/``) is the
671+
credential-leaking shape that http URL parsers will treat as
672+
``user@host``.
644673
* ``..`` path segments — httpx happily resolves
645674
``base_url='http://ha:8123/api'`` + endpoint ``'../auth/providers'``
646675
to ``http://ha:8123/auth/providers``, escaping the ``/api/``
647676
prefix entirely. HA exposes other bearer-authenticated routes
648677
at root (``/auth/...``, ``/profile``, etc.) — every one of
649678
those becomes reachable from the sandbox unless we reject
650-
``..`` here.
679+
``..`` here. Each segment is also percent-decoded once before
680+
the comparison so ``%2e%2e`` (and other percent-encoded
681+
variants of ``..``) can't slip past on reverse-proxy setups
682+
that decode-then-resolve.
651683
"""
652684
if not isinstance(endpoint, str):
653685
raise ValueError("endpoint must be a string path (e.g. '/states')")
@@ -666,12 +698,14 @@ def _normalize_endpoint(endpoint: Any) -> str:
666698
ep = ep[4:]
667699
# ``..`` segments would let the sandbox escape the ``/api/`` prefix
668700
# via httpx URL resolution. Check after stripping so the comparison
669-
# is against actual path segments, and return the same error for
670-
# leading-, mid-, or trailing-position cases.
701+
# is against actual path segments, and percent-decode each segment
702+
# so encoded forms (``%2e%2e`` and similar) don't slip past on
703+
# reverse-proxy setups that decode-then-resolve.
671704
for segment in ep.split("/"):
672-
if segment == "..":
705+
if urllib.parse.unquote(segment) == "..":
673706
raise ValueError(
674-
"endpoint must not contain '..' path segments; "
707+
"endpoint must not contain '..' path segments "
708+
"(including percent-encoded forms like %2e%2e); "
675709
"the sandbox is restricted to /api/ routes"
676710
)
677711
return ep
@@ -1169,9 +1203,15 @@ async def ha_manage_custom_tool(
11691203
)
11701204
)
11711205

1206+
# ``%r`` (repr) defends against log-line injection by escaping
1207+
# ``\r`` / ``\n`` / ``\t`` as literal ``\r``/``\n``/``\t``
1208+
# sequences in the formatted output — same primitive used by
1209+
# the audit-log endpoint/type fields below. Truncate to keep
1210+
# log lines bounded; an LLM can supply a multi-KB
1211+
# justification.
11721212
logger.info(
1173-
"ha_manage_custom_tool invoked — justification: %s",
1174-
_log_safe(justification),
1213+
"ha_manage_custom_tool invoked — justification: %r",
1214+
justification[:200],
11751215
)
11761216
# Code is logged at DEBUG and the multi-line shape is intentional
11771217
# (the LLM-authored snippet is the operator's primary forensic

tests/src/e2e/tools/test_create_custom_tool.py

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,39 +1410,74 @@ async def test_ws_send_blocks_lovelace_config_save(
14101410
)
14111411
logger.info("ws_send correctly blocked lovelace/config/save")
14121412

1413-
async def test_ws_send_blocks_registry_mutations(
1414-
self, mcp_client_with_code_mode
1413+
@pytest.mark.parametrize(
1414+
"ws_type",
1415+
[
1416+
# Each member of _BLOCKED_WS_COMMANDS must be rejected by
1417+
# ws_send. Listed explicitly so a regression that drops an
1418+
# entry from the frozenset surfaces as a missing parametrize
1419+
# row in CI rather than passing silently.
1420+
"config/core/update",
1421+
"lovelace/config/save",
1422+
"lovelace/dashboards/create",
1423+
"lovelace/dashboards/delete",
1424+
"lovelace/dashboards/update",
1425+
"config/area_registry/delete",
1426+
"config/area_registry/disable",
1427+
"config/area_registry/update",
1428+
"config/device_registry/delete",
1429+
"config/device_registry/disable",
1430+
"config/device_registry/update",
1431+
"config/device_registry/remove_config_entry",
1432+
"config/entity_registry/delete",
1433+
"config/entity_registry/disable",
1434+
"config/entity_registry/update",
1435+
"config/entity_registry/remove",
1436+
"config/floor_registry/create",
1437+
"config/floor_registry/delete",
1438+
"config/floor_registry/update",
1439+
"config/label_registry/create",
1440+
"config/label_registry/delete",
1441+
"config/label_registry/update",
1442+
"config/category_registry/create",
1443+
"config/category_registry/delete",
1444+
"config/category_registry/update",
1445+
],
1446+
)
1447+
async def test_ws_send_blocks_command(
1448+
self, mcp_client_with_code_mode, ws_type
14151449
):
1416-
"""Registry mutation commands are blocked — must go through their
1417-
wrapping tools (ha_config_set_area, ha_update_device, ha_set_entity).
1450+
"""Every entry in _BLOCKED_WS_COMMANDS must be rejected by ws_send.
1451+
1452+
Parametrizing over the full set catches the "blocklist names a
1453+
command HA Core doesn't actually accept" class of bug — if a
1454+
future refactor drops an entry, the corresponding row fails;
1455+
if HA Core renames a command and we forget to update the
1456+
blocklist, the test fails with the old name still in the
1457+
parametrize list.
14181458
"""
14191459
check = await _check_tool_available(mcp_client_with_code_mode)
1420-
_skip_if_unavailable(check, "ws_send registry mutation blocklist")
1460+
_skip_if_unavailable(check, f"ws_send blocks {ws_type}")
14211461

1422-
for ws_type in (
1423-
"config/area_registry/update",
1424-
"config/device_registry/update",
1425-
"config/entity_registry/update",
1426-
):
1427-
code = (
1428-
f'result = await ws_send({{"type": "{ws_type}", "id": "x"}})\n'
1429-
'{"has_error": "error" in result if isinstance(result, dict) else False,'
1430-
' "error": result.get("error", "") if isinstance(result, dict) else ""}'
1431-
)
1432-
data = await safe_call_tool(
1433-
mcp_client_with_code_mode,
1434-
TOOL_NAME,
1435-
{"code": code, "justification": f"E2E test: blocked {ws_type}"},
1436-
)
1437-
assert data.get("success") is True, f"Sandbox should succeed: {data}"
1438-
result = data["data"]["result"]
1439-
assert result["has_error"] is True, (
1440-
f"ws_send must block {ws_type}: {data}"
1441-
)
1442-
assert ws_type in result["error"], (
1443-
f"Error should mention the blocked command: {result}"
1444-
)
1445-
logger.info("ws_send correctly blocked %s", ws_type)
1462+
code = (
1463+
f'result = await ws_send({{"type": "{ws_type}", "id": "x"}})\n'
1464+
'{"has_error": "error" in result if isinstance(result, dict) else False,'
1465+
' "error": result.get("error", "") if isinstance(result, dict) else ""}'
1466+
)
1467+
data = await safe_call_tool(
1468+
mcp_client_with_code_mode,
1469+
TOOL_NAME,
1470+
{"code": code, "justification": f"E2E test: blocked {ws_type}"},
1471+
)
1472+
assert data.get("success") is True, f"Sandbox should succeed: {data}"
1473+
result = data["data"]["result"]
1474+
assert result["has_error"] is True, (
1475+
f"ws_send must block {ws_type}: {data}"
1476+
)
1477+
assert ws_type in result["error"], (
1478+
f"Error should mention the blocked command: {result}"
1479+
)
1480+
logger.info("ws_send correctly blocked %s", ws_type)
14461481

14471482
async def test_ws_send_allows_registry_list(self, mcp_client_with_code_mode):
14481483
"""Registry LIST queries stay allowed — only mutations are blocked."""

0 commit comments

Comments
 (0)