Skip to content

Commit ab23ffa

Browse files
kingpanther13claude
andcommitted
fix(code-mode): address round-2 review findings
Implements the 4 real bugs, 3 doc fixes, and 3 test gaps identified by the second-pass review of the persistence + blocklist work. Bugs ---- * ``_API_POST_BLOCKED_PREFIXES``: removed the ``config/scene/config/`` entry. The error message it returned told the LLM to use ``ha_config_set_scene``, which does not exist in the registered tool catalogue (no scene-related ``set`` tool exists at all). Blocking the REST path without a validated alternative was net-negative — it just removed capability. The block can come back when a wrapping tool lands; comment in the code records that decision. * ``_save_saved_tools`` now returns ``bool`` and the persistence failure path is no longer silent. The save_as branch in ``ha_manage_custom_tool`` rolls back the in-memory cache on a False return and surfaces a ``save_warning`` field in the response, with ``saved_as`` reset to ``None``. ``_delete_saved_tool`` also rolls back on failure and returns ``{"error": ...}`` instead of the prior misleading ``{"deleted": True}``. Persistence was the documented contract of this PR, so the prior "log at WARNING and lie with success: True" behaviour was a real reliability gap, not just cosmetic. * ``_load_saved_tools`` distinguishes ``FileNotFoundError`` (legitimate "starting empty") from other ``OSError``s (genuine I/O failure on an existing file). When the latter fires, a new module-level ``_saved_tools_load_failed`` flag suppresses subsequent persistence for the session — preventing a transient ``PermissionError`` at startup from cascading into "next save wipes out the unreadable file with empty content" data loss. The flag is cleared on the next successful load. * The audit-log line now uses ``sorted(map(str, data.keys()))`` instead of ``sorted(data.keys())``. Monty allows mixed-type dict keys, and the previous form would raise ``TypeError`` on the first ``api_post("/foo", {1: "x", "a": "y"})`` invocation, propagating through the audit-log step into the catch-all ``except Exception`` and surfacing as a generic "api_post failed" with no hint that the audit-log step was the real culprit. Schema-version contract honoured -------------------------------- ``_load_saved_tools`` now actually reads ``data.get("version")`` and refuses to interpret anything that isn't ``_SAVED_TOOLS_SCHEMA_VERSION`` (currently 1). The prior code wrote the version field but never checked it, so a future v2 file would have been silently downgraded to v1 semantics by current code. Mismatch sets the load-failed flag so we don't atomically replace the unfamiliar file with our v1 shape. Docs ---- * ``docs/beta.md`` audit-log paragraph now shows the correct ``configuration.yaml`` / ``logger.logs.<name>: debug`` snippet instead of the previous bogus "set ``logger ha_mcp.tools.tools_code: debug`` in your add-on configuration." (HA's logger integration lives in configuration.yaml, not addon options.) * The "blocked endpoints" paragraph in beta.md was updated to match the now-shorter ``_API_POST_BLOCKED_PREFIXES`` and explicitly notes the scene exception. * The stale ``ha_config_set_scene`` reference in the ``ha_config_set_yaml`` Known Limitations section (preexisting) was also removed since I was in the file. * The ``_MAX_SAVED_TOOLS`` constant comment now mentions both load- and save-time enforcement; the ``_SAVED_TOOLS_SCHEMA_VERSION`` comment matches reality (the load path actually consults it). * The ``_API_POST_BLOCKED_PREFIXES`` block comment splits the conflated rationale into two flavours (no-legitimate-use-case vs has-wrapping-tool). Tests ----- * ``TestSaveSavedTools.test_returns_true_on_success`` and ``test_returns_true_when_path_unset`` pin the new bool contract. * ``TestSchemaVersionGuard`` (3 tests) covers refusing unknown version, missing version field, and not overwriting an unfamiliar file. * ``TestLoadFailedFlag`` (2 tests) covers flag-clear-on-success and save-skipped-when-set. * ``TestHydrationRoundTrip`` (2 tests) covers the load→modify→save→ reload lifecycle, which the original suite was missing. * ``TestSaveCapEnforcement`` (2 tests) covers the load-side cap and pins that ``_save_saved_tools`` itself does not self-cap (the registration-site code is the upper-bound guard). 24 unit tests now pass (was 13). Lint/mypy/ast-grep clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a08348d commit ab23ffa

3 files changed

Lines changed: 393 additions & 38 deletions

File tree

docs/beta.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ This tool edits `configuration.yaml` and package files directly, bypassing Home
5555

5656
**Recommended prerequisites:**
5757
- Comfort with editing `configuration.yaml` via SSH or File Editor when things go wrong
58-
- Understanding that dedicated tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_set_script`, `ha_config_set_scene`, etc.) should be preferred for anything they support
58+
- Understanding that dedicated tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_set_script`, etc.) should be preferred for anything they support
5959

6060
### `ha_list_files`, `ha_read_file`, `ha_write_file`, `ha_delete_file`
6161

@@ -85,13 +85,25 @@ This tool exposes a sandboxed Python interpreter (`pydantic-monty`) to the AI as
8585

8686
**Safer-path enforcement on REST and WebSocket.** Several endpoints have wrapping MCP tools that perform validation, lint, hash-locking, or invariant checks; raw `api_post` / `ws_send` would skip those. The sandbox blocks a small denylist on each surface:
8787

88-
- `api_post`: writes to `/api/states/<entity_id>` (which can conjure ghost entities), `/api/events/<HA-internal-event-name>` (Core internal events that can fan out into user automations), and `/api/config/{automation,script,scene}/config/*` (forced through `ha_config_set_automation` / `ha_config_set_script` / `ha_config_set_scene`).
88+
- `api_post`: writes to `/api/states/<entity_id>` (which can conjure ghost entities), `/api/events/<HA-internal-event-name>` (Core internal events that can fan out into user automations), and `/api/config/{automation,script}/config/*` (forced through `ha_config_set_automation` / `ha_config_set_script`). `config/scene/config/*` is intentionally not blocked because no `ha_config_set_scene` wrapping tool exists yet — the block would just remove capability with no validated alternative path.
8989
- `ws_send`: `config/core/update` (rewrites HA's location/timezone/currency in `.storage/core.config`), `lovelace/config/save` and `lovelace/dashboards/{create,delete,update}` (forced through `ha_config_set_dashboard`), and `config/{area,device,entity}_registry/{delete,disable,update}` (forced through `ha_config_set_area` / `ha_update_device` / `ha_set_entity` etc.).
9090
- Service calls (`POST /api/services/<domain>/<service>`), webhook firing (`POST /api/webhook/<id>`), custom event types (`POST /api/events/my_event_name`), and registry **read** queries (e.g. `config/area_registry/list`) all stay allowed.
9191

9292
**Sandbox failures are classified.** When sandboxed code raises, the error response now uses one of three codes — `SANDBOX_LIMIT_EXCEEDED` (memory / time / recursion / invocation cap), `SANDBOX_SYNTAX_UNSUPPORTED` (imports, classes, `with`, `match`, hard syntax errors) or `SANDBOX_RUNTIME_ERROR` (everything else) — with suggestions tailored to the category. Previously every Monty failure surfaced as `INTERNAL_ERROR` with "check the Python code for syntax errors" advice, which actively misled callers when the real cause was a memory cap or a missing module import.
9393

94-
**Sandbox actions are auditable.** Every state-changing sandbox call (`POST /api/...`, every `ws_send`) logs a structured `sandbox.api_post`/`sandbox.ws_send` line at DEBUG level. Blocked attempts log at INFO level. To get a forensic trail, set `logger ha_mcp.tools.tools_code: debug` (or equivalent) in your add-on configuration.
94+
**Sandbox actions are auditable.** Every state-changing sandbox call (`POST /api/...`, every `ws_send`) logs a structured `sandbox.api_post` / `sandbox.ws_send` line at DEBUG level. Blocked attempts (e.g. a refused `POST /api/states/...` or a refused `config/core/update`) log a `sandbox.api_post.blocked` / `sandbox.ws_send.blocked` line at INFO level so they're visible in default operator logs.
95+
96+
To get a full forensic trail of allowed calls, escalate the `ha_mcp.tools.tools_code` logger to DEBUG. This is HA's [`logger:` integration](https://www.home-assistant.io/integrations/logger/) and goes in **`configuration.yaml`** (not the add-on options):
97+
98+
```yaml
99+
# configuration.yaml
100+
logger:
101+
default: warning
102+
logs:
103+
ha_mcp.tools.tools_code: debug
104+
```
105+
106+
Reload the `Logger` integration (or restart HA) to apply.
95107

96108
**ARM platforms require the async sandbox path.** On systems where `Monty.run_async` is unavailable, the tool fails fast with a clear error rather than falling back silently.
97109

src/ha_mcp/tools/tools_code.py

Lines changed: 165 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,21 @@
5252
# Validation for save_as names
5353
_SAVE_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
5454

55-
# Path-prefix denylist for ``_api_post``. These endpoints either have no
56-
# legitimate sandbox use case or have a wrapping MCP tool that performs
57-
# validation/lint/hash-locking that raw ``api_post`` would skip. The
58-
# prefixes are matched after ``_normalize_endpoint`` strips the leading
59-
# ``api/`` so they are written as plain HA-relative paths.
55+
# Path-prefix denylist for ``_api_post``. Two flavours of entry, kept in
56+
# one list because the matching logic is identical:
57+
# 1. Endpoints with no legitimate sandbox use case at all — currently
58+
# just ``states/`` (raw state writes can conjure ghost entities and
59+
# override real ones in the in-memory state machine).
60+
# 2. Endpoints whose corresponding wrapping MCP tool performs
61+
# validation / lint / hash-locking that raw ``api_post`` would skip
62+
# — currently ``config/{automation,script}/config/``.
63+
# Scene config writes (``config/scene/config/*``) are intentionally NOT
64+
# in this list: there is no ``ha_config_set_scene`` tool to redirect to,
65+
# and blocking the path without offering a substitute would just remove
66+
# capability with no validated alternative. Add the block back when a
67+
# wrapping tool lands.
68+
# The prefixes are matched after ``_normalize_endpoint`` strips the
69+
# leading ``api/`` so they are written as plain HA-relative paths.
6070
_API_POST_BLOCKED_PREFIXES: tuple[tuple[str, str, str], ...] = (
6171
(
6272
"states/",
@@ -75,11 +85,6 @@
7585
"Direct writes to /api/config/script/config/*",
7686
"use call_tool('ha_config_set_script', ...)",
7787
),
78-
(
79-
"config/scene/config/",
80-
"Direct writes to /api/config/scene/config/*",
81-
"use call_tool('ha_config_set_scene', ...)",
82-
),
8388
)
8489

8590
# HA Core internal events. Firing one of these via POST /api/events/<name>
@@ -266,23 +271,46 @@ def _check_api_post_blocked(normalized: str) -> str | None:
266271
)
267272
return None
268273

269-
# Cap on the number of saved tools to prevent runaway growth (a buggy LLM
270-
# loop could otherwise fill disk with unique save_as names).
274+
# Cap on the number of saved tools to prevent runaway growth. A buggy
275+
# LLM loop could otherwise fill the on-disk file with unique save_as
276+
# names. Enforced both at load (truncate-with-warning) and at save
277+
# (reject the call before mutating the in-memory cache).
271278
_MAX_SAVED_TOOLS = 256
272279

273-
# Schema version for the on-disk saved-tools file. Bump when the shape
274-
# changes so _load_saved_tools can migrate or refuse old files cleanly.
280+
# Schema version for the on-disk saved-tools file. Bumped when the shape
281+
# changes so _load_saved_tools can refuse old/new files cleanly. The
282+
# load path checks data["version"] explicitly and refuses anything that
283+
# isn't this number rather than silently re-interpreting it as v1.
275284
_SAVED_TOOLS_SCHEMA_VERSION = 1
276285

286+
# Module-level flag set when _load_saved_tools fails for a reason other
287+
# than "the file doesn't exist yet" (e.g. PermissionError reading an
288+
# existing file). Persistence is suppressed while this is set so we
289+
# don't atomically replace a temporarily-unreadable file with empty
290+
# content and destroy whatever was on disk. Cleared by a successful
291+
# load at register_code_tools time. The variable is module-level
292+
# (not closure-captured) so save sites in ha_manage_custom_tool /
293+
# _delete_saved_tool can read it without parameter plumbing.
294+
_saved_tools_load_failed = False
295+
277296

278297
def _load_saved_tools(path_str: str) -> dict[str, dict[str, str]]:
279298
"""Load saved tools from a JSON file, filtering malformed entries.
280299
281-
Returns an empty dict if the path is unset, the file doesn't exist
282-
yet, or the contents are unreadable / unparseable. A corrupt file is
283-
logged at WARNING but does not raise — the user can still save new
284-
tools and the bad file will be overwritten on the next persist.
300+
Returns an empty dict if the path is unset or the file doesn't exist
301+
yet (legitimate "starting empty" cases). A corrupt JSON body or an
302+
unexpected schema version is logged at WARNING and returns empty —
303+
the file will be overwritten on the next persist.
304+
305+
A genuine I/O error reading an existing file (OSError that isn't
306+
FileNotFoundError) is logged at ERROR and ALSO sets the module-level
307+
``_saved_tools_load_failed`` flag so callers know not to overwrite
308+
whatever is on disk while the load condition persists. This prevents
309+
a PermissionError at startup from cascading into "next save wipes
310+
out the unreadable file" data loss.
285311
"""
312+
global _saved_tools_load_failed
313+
_saved_tools_load_failed = False
286314
if not path_str:
287315
return {}
288316
path = Path(path_str)
@@ -291,10 +319,31 @@ def _load_saved_tools(path_str: str) -> dict[str, dict[str, str]]:
291319
return {}
292320
try:
293321
raw = path.read_text(encoding="utf-8")
322+
except FileNotFoundError:
323+
# Race: file disappeared between exists() and read_text().
324+
# Treat as legitimate "not yet" rather than an I/O failure.
325+
return {}
326+
except OSError as exc:
327+
# PermissionError / IsADirectoryError / etc. The file exists but
328+
# we can't read it. Block subsequent persistence so we don't
329+
# overwrite the unreadable original with empty content.
330+
logger.error(
331+
"Cannot read saved-tools file %s (%s); persistence will be "
332+
"suppressed until the load condition clears. Saves and "
333+
"deletes will still update the in-memory cache for the "
334+
"current session.",
335+
path,
336+
exc,
337+
exc_info=True,
338+
)
339+
_saved_tools_load_failed = True
340+
return {}
341+
try:
294342
data = json.loads(raw)
295-
except (OSError, json.JSONDecodeError) as exc:
343+
except json.JSONDecodeError as exc:
296344
logger.warning(
297-
"Failed to load saved tools from %s (%s); starting empty",
345+
"Saved-tools file %s is not valid JSON (%s); starting empty. "
346+
"The corrupt file will be overwritten on the next persist.",
298347
path,
299348
exc,
300349
)
@@ -308,6 +357,25 @@ def _load_saved_tools(path_str: str) -> dict[str, dict[str, str]]:
308357
)
309358
return {}
310359

360+
file_version = data.get("version")
361+
if file_version != _SAVED_TOOLS_SCHEMA_VERSION:
362+
# Refuse to interpret the file. We don't know whether this is a
363+
# newer file produced by a future ha-mcp version (which might
364+
# have shape changes we'd silently mangle) or an older file we
365+
# don't have a migration for. Setting the failed flag means the
366+
# current session won't overwrite it on next save.
367+
logger.error(
368+
"Saved-tools file %s has schema version %r; this build expects %d. "
369+
"Refusing to load. Persistence is suppressed for this session "
370+
"to avoid overwriting an unfamiliar file. Move or delete the "
371+
"file to recover.",
372+
path,
373+
file_version,
374+
_SAVED_TOOLS_SCHEMA_VERSION,
375+
)
376+
_saved_tools_load_failed = True
377+
return {}
378+
311379
tools_raw = data.get("saved_tools", {})
312380
if not isinstance(tools_raw, dict):
313381
logger.warning(
@@ -353,17 +421,32 @@ def _load_saved_tools(path_str: str) -> dict[str, dict[str, str]]:
353421
return valid
354422

355423

356-
def _save_saved_tools(path_str: str, tools: dict[str, dict[str, str]]) -> None:
424+
def _save_saved_tools(
425+
path_str: str, tools: dict[str, dict[str, str]]
426+
) -> bool:
357427
"""Persist the saved-tools cache to a JSON file atomically.
358428
359-
Writes to ``path.tmp`` first and uses ``os.replace`` to swap it in,
360-
so a crash mid-write cannot corrupt the existing file. Failures are
361-
logged at WARNING — a write failure does not raise into the sandbox
362-
or the MCP client because the in-memory cache still holds the new
363-
entry; persistence is best-effort.
429+
Returns ``True`` if persistence succeeded (or was disabled because
430+
``path_str`` is empty — that's the configured-out case, not a
431+
failure). Returns ``False`` only when persistence was attempted and
432+
the underlying I/O raised. Callers that promised the user durability
433+
should surface a ``False`` return as a warning in the response.
434+
435+
Writes to ``<dir>/.<name>.<rand>.tmp`` first and uses ``os.replace``
436+
to swap it in, so a crash mid-write cannot corrupt the existing
437+
file. Refuses to write at all when ``_saved_tools_load_failed`` is
438+
set — see _load_saved_tools for why we'd rather skip persistence
439+
than overwrite an unreadable file with empty content.
364440
"""
365441
if not path_str:
366-
return
442+
return True
443+
if _saved_tools_load_failed:
444+
logger.warning(
445+
"Skipping persist to %s because the prior load failed; "
446+
"saves and deletes are in-memory only for this session.",
447+
path_str,
448+
)
449+
return False
367450
path = Path(path_str)
368451
payload = {
369452
"version": _SAVED_TOOLS_SCHEMA_VERSION,
@@ -387,11 +470,16 @@ def _save_saved_tools(path_str: str, tools: dict[str, dict[str, str]]) -> None:
387470
tmp_path = Path(tmp.name)
388471
tmp_path.replace(path)
389472
except OSError as exc:
390-
logger.warning(
391-
"Failed to persist saved tools to %s (%s); cache remains in memory",
473+
logger.error(
474+
"Failed to persist saved tools to %s (%s); the in-memory "
475+
"cache holds the latest change but it will be lost on restart "
476+
"unless this resolves before the next save",
392477
path,
393478
exc,
479+
exc_info=True,
394480
)
481+
return False
482+
return True
395483

396484

397485
def _extract_tool_result(result: Any) -> Any:
@@ -560,10 +648,15 @@ async def _api_post(endpoint: str, data: dict[str, Any] | None = None) -> Any:
560648
# State-changing call: DEBUG-level audit trail. Operators can
561649
# bump the ha_mcp.tools.tools_code logger to DEBUG to see what
562650
# the sandbox is actually doing on their HA instance.
651+
# ``map(str, ...)`` on the keys because Monty allows mixed-type
652+
# dict keys (e.g. ``{1: "x", "a": "y"}``); a plain ``sorted``
653+
# would raise TypeError on the first invocation and the user
654+
# would see a confusing "api_post failed" with no hint that
655+
# the audit-log step was the real culprit.
563656
logger.debug(
564657
"sandbox.api_post endpoint=%r data_keys=%s",
565658
endpoint,
566-
sorted(data.keys()) if isinstance(data, dict) else None,
659+
sorted(map(str, data.keys())) if isinstance(data, dict) else None,
567660
)
568661
try:
569662
post_kwargs: dict[str, Any] = {}
@@ -670,9 +763,11 @@ def _delete_saved_tool(name: Any) -> dict[str, Any]:
670763
"""Remove a previously saved custom tool by name.
671764
672765
Sandbox helper. Returns ``{"deleted": True, "name": name}`` on
673-
success, ``{"error": "..."}`` on validation failure or if the
674-
named tool does not exist. Persists the change immediately when
675-
the saved-tools file path is configured.
766+
success, ``{"error": "..."}`` on validation failure, missing
767+
name, or persistence failure. When persistence is configured
768+
and the on-disk write fails, the in-memory deletion is rolled
769+
back so the next save_as / list_saved doesn't show a different
770+
view than the next process restart.
676771
"""
677772
if not isinstance(name, str):
678773
return {"error": "delete_saved_tool(name) requires a string name"}
@@ -685,9 +780,24 @@ def _delete_saved_tool(name: Any) -> dict[str, Any]:
685780
}
686781
if name not in _saved_tools:
687782
return {"error": f"No saved tool named {name!r}"}
783+
# Snapshot the entry before deleting so we can restore it on
784+
# persist failure (otherwise the in-memory cache and disk would
785+
# disagree, and the on-restart hydration would resurrect the
786+
# entry the LLM already saw "deleted").
787+
previous = _saved_tools[name]
688788
del _saved_tools[name]
789+
if not _save_saved_tools(
790+
settings.code_mode_saved_tools_path, _saved_tools
791+
):
792+
_saved_tools[name] = previous
793+
return {
794+
"error": (
795+
f"Deleted {name!r} from in-memory cache but the "
796+
"persistence write failed; rolled back. Check "
797+
"operator logs for the underlying I/O error."
798+
)
799+
}
689800
logger.info("Deleted saved custom tool '%s'", name)
690-
_save_saved_tools(settings.code_mode_saved_tools_path, _saved_tools)
691801
return {"deleted": True, "name": name}
692802

693803
m = Monty(code, script_name="ha_manage_custom_tool.py")
@@ -1021,12 +1131,32 @@ async def ha_manage_custom_tool(
10211131
],
10221132
)
10231133
)
1134+
previous = _saved_tools.get(save_as)
10241135
_saved_tools[save_as] = {
10251136
"code": code,
10261137
"justification": justification,
10271138
}
10281139
response["data"]["saved_as"] = save_as
10291140
logger.info("Saved custom tool as '%s'", save_as)
1030-
_save_saved_tools(settings.code_mode_saved_tools_path, _saved_tools)
1141+
persisted = _save_saved_tools(
1142+
settings.code_mode_saved_tools_path, _saved_tools
1143+
)
1144+
if not persisted:
1145+
# Roll back the in-memory write so the cache matches
1146+
# what's on disk (or, on next restart, what's loaded).
1147+
# Surface a warning in the response so the LLM knows
1148+
# the save_as didn't actually durable, while still
1149+
# returning success=True for the code execution itself.
1150+
if previous is None:
1151+
_saved_tools.pop(save_as, None)
1152+
else:
1153+
_saved_tools[save_as] = previous
1154+
response["data"]["saved_as"] = None
1155+
response["data"]["save_warning"] = (
1156+
f"save_as={save_as!r} was attempted but the persistence "
1157+
"write failed; the entry was rolled back from the "
1158+
"in-memory cache. Check operator logs for the "
1159+
"underlying I/O error."
1160+
)
10311161

10321162
return response

0 commit comments

Comments
 (0)