Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
54dfb11
feat: add sandboxed code execution tool (ha_execute_code)
kingpanther13 Apr 2, 2026
d66a80d
refactor: rename ha_execute_code to ha_create_custom_tool
kingpanther13 Apr 2, 2026
27c6576
fix: resolve CI failures — update uv.lock and fix mypy errors
kingpanther13 Apr 2, 2026
5093cd4
feat: add recursion limit, E2E tests for ha_create_custom_tool
kingpanther13 Apr 2, 2026
e53a235
fix: correct relative import path in E2E tests
kingpanther13 Apr 2, 2026
71538f4
feat: add saved tools cache (ha_run_saved_tool, ha_list_saved_tools)
kingpanther13 Apr 2, 2026
1802c5a
feat: pin code mode tools when tool search is active
kingpanther13 Apr 2, 2026
c77e663
fix: remove unused type: ignore after refactor
kingpanther13 Apr 2, 2026
5e6d4f9
fix: add fallback for Monty.run_async on ARM
kingpanther13 Apr 2, 2026
9707906
fix: use safe_call_tool for saved tools tests
kingpanther13 Apr 2, 2026
177a995
refactor: consolidate to single ha_manage_custom_tool, fix review issues
kingpanther13 Apr 2, 2026
e581ed4
fix: handle ToolResult serialization, fix ruff import order
kingpanther13 Apr 2, 2026
0e3120f
fix: ruff import sorting, robust entity search test
kingpanther13 Apr 2, 2026
4219788
fix: correct ha_search_entities result shape in test and docstring
kingpanther13 Apr 2, 2026
33ee33a
fix: correct result shape and import sorting for CI
kingpanther13 Apr 2, 2026
4e9dd67
feat: expose enable_code_mode in add-on configuration
kingpanther13 Apr 2, 2026
af5b015
feat: add translation strings for enable_code_mode toggle
kingpanther13 Apr 2, 2026
8ce9ff2
feat: add direct HA REST API access (api_get/api_post) to sandbox
kingpanther13 Apr 2, 2026
bbcc5ad
test: add E2E tests for api_get/api_post error handling and payloads
kingpanther13 Apr 2, 2026
68bda1d
fix: remove /api/ prefix from sandbox API endpoints
kingpanther13 Apr 2, 2026
a335b86
fix: remove missed /api/ prefix from sun.sun endpoint test
kingpanther13 Apr 2, 2026
d3738b1
fix: use ternary expression in sun.sun test for Monty compatibility
kingpanther13 Apr 2, 2026
925db7e
fix: address Gemini review — docs, configurable limits, code cleanup
kingpanther13 Apr 2, 2026
b715469
fix: normalize api_get/api_post endpoints to prevent leading-slash pa…
kingpanther13 Apr 3, 2026
1602427
fix: use structured error responses in sandbox call_tool helper
kingpanther13 Apr 9, 2026
91357cc
fix(test): create fresh server in code mode E2E fixture
kingpanther13 Apr 9, 2026
eaddd22
fix: sandbox errors return dicts (not exceptions) and clean up env va…
kingpanther13 Apr 9, 2026
5a5cb00
refactor(addon): move ha_manage_custom_tool to dev channel only (beta)
kingpanther13 May 5, 2026
1b76f71
feat(code-mode): add ws_send WebSocket helper to sandbox
kingpanther13 May 5, 2026
96c03ec
fix(deps): bump mypy to 1.20.2 in lockfile
kingpanther13 May 5, 2026
2fc8d04
chore(tests): drop unrelated living_room_dashboard E2E carryover
kingpanther13 May 5, 2026
b7bc98d
fix(code-mode): block URL injection, tighten error handling and valid…
kingpanther13 May 5, 2026
5375077
fix(code-mode): drop success:False from _extract_tool_result error re…
kingpanther13 May 5, 2026
57338b2
feat(code-mode): persist saved tools to disk + add delete_saved_tool …
kingpanther13 May 5, 2026
d4550ec
fix(code-mode): add api_post/ws_send blocklists, classify sandbox err…
kingpanther13 May 5, 2026
f264b78
fix(code-mode): address round-2 review findings
kingpanther13 May 5, 2026
97a963e
fix(test): split scene/script blocklist test, add positive scene case
kingpanther13 May 5, 2026
d1122b9
fix(code-mode): close proxy-laundering, ..-traversal, and 5 other Pat…
kingpanther13 May 6, 2026
5e2f878
fix(test): rewrite memory test for Monty, skip unreachable recursion …
kingpanther13 May 6, 2026
58899e8
fix(code-mode): address Patch76 round-3 review (H1, M2, M3, L1-L6) + …
kingpanther13 May 6, 2026
8c00740
test(code-mode): pin %2e%2e traversal rejection + naked-list passthro…
kingpanther13 May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions docs/beta.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Some ha-mcp tools are gated behind feature flags and available only in the **dev
| `ha_write_file` | `enable_filesystem_tools` (dev add-on) / `HAMCP_ENABLE_FILESYSTEM_TOOLS=true` (env var) | Write files to allowed directories. Requires `ha_mcp_tools` custom component. |
| `ha_delete_file` | `enable_filesystem_tools` (dev add-on) / `HAMCP_ENABLE_FILESYSTEM_TOOLS=true` (env var) | Delete files from allowed directories. Requires `ha_mcp_tools` custom component. |
| `ha_install_mcp_tools` | `enable_custom_component_integration` (dev add-on) / `HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION=true` (env var) | Installs the `ha_mcp_tools` custom component via HACS. |
| `ha_manage_custom_tool` | `enable_code_mode` (dev add-on) / `ENABLE_CODE_MODE=true` (env var) | Sandboxed Python "escape hatch" that lets AI assistants write, run, save, and delete custom tools when no built-in tool covers the request. Code runs in pydantic-monty (no filesystem, no network); sandbox can call the HA REST API (`api_get`/`api_post`), send WebSocket commands (`ws_send`), call registered MCP tools (`call_tool`), or delete a saved tool (`delete_saved_tool`). Saved tools persist to disk via `CODE_MODE_SAVED_TOOLS_PATH` (defaults to `/data/saved_tools.json` in the dev add-on). |

## How to enable

Expand All @@ -23,7 +24,7 @@ Some ha-mcp tools are gated behind feature flags and available only in the **dev
4. Enable the desired toggle (e.g., `enable_yaml_config_editing`, `enable_filesystem_tools`).
5. Restart the add-on.

`enable_yaml_config_editing`, `enable_filesystem_tools`, and `enable_custom_component_integration` are only available in the dev channel add-on. The stable add-on does not expose these beta toggles.
`enable_yaml_config_editing`, `enable_filesystem_tools`, `enable_custom_component_integration`, and `enable_code_mode` are only available in the dev channel add-on. The stable add-on does not expose these beta toggles.

### Option 2: Environment variable (non-add-on installs)

Expand Down Expand Up @@ -54,7 +55,7 @@ This tool edits `configuration.yaml` and package files directly, bypassing Home

**Recommended prerequisites:**
- Comfort with editing `configuration.yaml` via SSH or File Editor when things go wrong
- 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
- Understanding that dedicated tools (`ha_config_set_helper`, `ha_config_set_automation`, `ha_config_set_script`, etc.) should be preferred for anything they support

### `ha_list_files`, `ha_read_file`, `ha_write_file`, `ha_delete_file`

Expand All @@ -67,3 +68,45 @@ These tools provide direct file access to your Home Assistant filesystem and req
**No undo.** `ha_delete_file` and `ha_write_file` (with `overwrite=True`) are irreversible. There is no recycle bin or automatic backup for file operations.

**Requires the custom component.** If `ha_mcp_tools` is not installed and active, all file tools will return an error with installation instructions.

### `ha_manage_custom_tool`

This tool exposes a sandboxed Python interpreter (`pydantic-monty`) to the AI as an escape hatch for operations no built-in tool covers. It also lets the AI save tools for reuse via `save_as` / `run_saved` / `list_saved`, and delete them from inside the sandbox via `delete_saved_tool(name)`. Sandbox code can hit the HA REST API directly (`api_get`/`api_post`), send HA WebSocket commands (`ws_send`), or call other registered MCP tools (`call_tool`). The sandbox blocks filesystem and arbitrary network I/O, but operators should still be aware of the following:

**The AI gets to write and run code on your HA instance.** Even though the sandbox prevents it from touching the filesystem or the public network, code can still call any tool the MCP server has registered, including write/destructive tools, and can hit any endpoint reachable via the HA REST or WebSocket API. The WebSocket surface in particular covers most registry CRUD (areas, devices, entities, automations) and template rendering — so this is effectively "do whatever HA's own UI can do, in any combination." Treat this like giving the AI a generic "do whatever existing tools allow you to do, in any combination" capability — not a tightly scoped per-feature tool.

**Saved tools persist by default in the dev add-on.** Tools the AI saves via `save_as` are written to `CODE_MODE_SAVED_TOOLS_PATH` (defaults to `/data/saved_tools.json` in the add-on) and re-loaded on the next start. The cap is 256 saved tools per instance. Operators who want a clean slate can stop the add-on and delete the file. Operators migrating between environments can copy that JSON file to the new instance — it survives add-on updates, but **not** add-on uninstall/reinstall (the `/data` volume is recreated). Outside the add-on (pip / uvx / Docker direct), persistence is opt-in: set `CODE_MODE_SAVED_TOOLS_PATH=/path/to/tools.json` to enable.

**Recursive self-call is blocked, but composition is not.** The sandbox refuses to invoke `ha_manage_custom_tool` from inside itself, so it can't directly recurse, but it can chain together every other tool the server registers. A buggy or adversarial prompt can still cause unexpected fan-out across destructive tools.

**Resource limits are best-effort.** 30s wall-clock, 10 MB memory, recursion depth 100, and 100 API/tool calls per execution are enforced by the sandbox runtime; the per-execution call cap is enforced by ha-mcp itself. All four are configurable via the `CODE_MODE_MAX_*` env vars within the bounds defined in `src/ha_mcp/config.py`. They protect against runaway loops, not against intentionally crafted abuse — keep `ENABLE_CODE_MODE=false` in any environment where untrusted prompts can reach the server.

**Outbound HTTP is restricted to your HA instance.** `api_get` / `api_post` reject absolute URLs (`http://...`, `https://...`), protocol-relative URLs (`//host/...`), and userinfo (`user@host/...`). This stops a prompt-injected LLM from redirecting the request elsewhere and exfiltrating the HA bearer token via the still-attached `Authorization` header. Only HA-relative paths reach the underlying httpx client.

**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:

- `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.
- `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.).
- 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.

**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.

**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.

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):

```yaml
# configuration.yaml
logger:
default: warning
logs:
ha_mcp.tools.tools_code: debug
```

Reload the `Logger` integration (or restart HA) to apply.

**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.

**Recommended prerequisites:**
- You're comfortable with the AI authoring small Python snippets that wrap existing tools or HA REST endpoints
- You have `destructiveHint=True` confirmation enabled on the MCP client and you actually read the prompts
2 changes: 2 additions & 0 deletions homeassistant-addon-dev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ options:
backup_hint: "normal"
enable_tool_search: false
enable_yaml_config_editing: false
enable_code_mode: false
tool_search_max_results: 5
disabled_tools: ""
pinned_tools: ""
Expand All @@ -34,6 +35,7 @@ schema:
secret_path: str?
enable_tool_search: bool?
enable_yaml_config_editing: bool?
enable_code_mode: bool?
enable_filesystem_tools: bool?
enable_custom_component_integration: bool?
tool_search_max_results: int(2,10)?
Expand Down
13 changes: 13 additions & 0 deletions homeassistant-addon-dev/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ configuration:
require a full HA restart to take effect. See docs/beta.md for known
limitations. Dedicated tools (automations, scripts, scenes, helpers,
template sensors) should be preferred when available.
enable_code_mode:
name: Enable custom tool sandbox (beta)
description: >-
Beta feature — disabled by default. Enables the ha_manage_custom_tool
tool, which lets AI assistants create, run, save, and delete custom
Python code in a secure sandbox when no built-in tool can handle the
request. Code runs in an isolated interpreter with no filesystem or
arbitrary network access. Sandbox code can hit the HA REST API
(api_get/api_post), send WebSocket commands (ws_send), call existing
MCP tools (call_tool), or remove a saved tool (delete_saved_tool).
Saved tools persist to /data/saved_tools.json by default so they
survive add-on restarts. See docs/beta.md for known limitations.
Requires restart to take effect.
enable_filesystem_tools:
name: Enable filesystem tools (beta)
description: >-
Expand Down
9 changes: 5 additions & 4 deletions homeassistant-addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ host_network: true
# Use pre-built Docker images
image: "ghcr.io/homeassistant-ai/ha-mcp-addon-{arch}"
# Options for user configuration
# NOTE: enable_yaml_config_editing, enable_filesystem_tools, and
# enable_custom_component_integration are intentionally NOT exposed here.
# They are dev-channel-only beta features — see docs/beta.md. Do not re-mirror
# from homeassistant-addon-dev/config.yaml without reading that doc first.
# NOTE: enable_yaml_config_editing, enable_filesystem_tools,
# enable_custom_component_integration, and enable_code_mode are
# intentionally NOT exposed here. They are dev-channel-only beta
# features — see docs/beta.md. Do not re-mirror from
# homeassistant-addon-dev/config.yaml without reading that doc first.
options:
backup_hint: "normal"
enable_tool_search: false
Expand Down
15 changes: 15 additions & 0 deletions homeassistant-addon/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ def main() -> int:
enable_yaml_config_editing = False # default
enable_filesystem_tools = False # default
enable_custom_component_integration = False # default
enable_code_mode = False # default
tool_search_max_results = 5 # default
disabled_tools_raw = "" # default
pinned_tools_raw = "" # default
Expand All @@ -236,6 +237,8 @@ def main() -> int:
enable_filesystem_tools = raw_filesystem_tools if isinstance(raw_filesystem_tools, bool) else False
raw_custom_component = config.get("enable_custom_component_integration", False)
enable_custom_component_integration = raw_custom_component if isinstance(raw_custom_component, bool) else False
raw_code_mode = config.get("enable_code_mode", False)
enable_code_mode = raw_code_mode if isinstance(raw_code_mode, bool) else False
raw_max_results = config.get("tool_search_max_results", 5)
tool_search_max_results = raw_max_results if isinstance(raw_max_results, int) else 5
raw_disabled = config.get("disabled_tools", "")
Expand Down Expand Up @@ -274,6 +277,18 @@ def main() -> int:
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
os.environ["HAMCP_ENABLE_FILESYSTEM_TOOLS"] = str(enable_filesystem_tools).lower()
os.environ["HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"] = str(enable_custom_component_integration).lower()
os.environ["ENABLE_CODE_MODE"] = str(enable_code_mode).lower()
# Persist saved custom tools across addon restarts. /data is the
# per-addon writable directory mapped by Supervisor and survives
# add-on updates (but not uninstall/reinstall — users should copy
# this file out before reinstalling if they want to migrate).
# Setting this unconditionally is safe: on the stable add-on the
# tool isn't registered anyway, so the file is never read or
# written. Operators can override by setting CODE_MODE_SAVED_TOOLS_PATH
# in the add-on's environment if they want a different location.
os.environ.setdefault(
"CODE_MODE_SAVED_TOOLS_PATH", "/data/saved_tools.json"
)
os.environ["TOOL_SEARCH_MAX_RESULTS"] = str(tool_search_max_results)
os.environ["DISABLED_TOOLS"] = disabled_tools_raw
os.environ["PINNED_TOOLS"] = pinned_tools_raw
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies = [
"truststore==0.10.4",
"websockets==16.0",
"cryptography==47.0.0",
"pydantic-monty==0.0.9",
]

[project.urls]
Expand Down Expand Up @@ -75,6 +76,8 @@ explicit_package_bases = true
module = [
"fastmcp.*",
"jq",
"pydantic_monty",
"pydantic_monty.*",
]
ignore_missing_imports = true

Expand Down
31 changes: 31 additions & 0 deletions src/ha_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,37 @@ class Settings(BaseSettings):
# supervisor UI rejects out-of-range values before they reach env vars.
tool_search_max_results: int = Field(5, ge=2, le=10, alias="TOOL_SEARCH_MAX_RESULTS")

# Code Mode — sandboxed Python execution via pydantic-monty.
# Provides an "escape hatch" tool (ha_manage_custom_tool) that lets LLMs write
# custom one-off Python code when no existing tool covers the request.
# Disabled by default due to the inherent risk of LLM-generated code.
# Range bounds reject zero/negative values that would silently break the
# tool and clamp upper bounds at sane safety margins (5 min wall-clock,
# 256 MB memory, 10k recursion, 10k API/tool calls per execution).
enable_code_mode: bool = Field(False, alias="ENABLE_CODE_MODE")
code_mode_max_duration: float = Field(
30.0, ge=1.0, le=300.0, alias="CODE_MODE_MAX_DURATION"
)
code_mode_max_memory: int = Field(
10_485_760, ge=1_048_576, le=268_435_456, alias="CODE_MODE_MAX_MEMORY"
) # 10 MB default; 1 MB floor, 256 MB ceiling
code_mode_max_recursion: int = Field(
100, ge=1, le=10_000, alias="CODE_MODE_MAX_RECURSION"
)
code_mode_max_invocations: int = Field(
100, ge=1, le=10_000, alias="CODE_MODE_MAX_INVOCATIONS"
)
# Path to a JSON file for persisting saved custom tools across restarts.
# Empty string disables persistence (saved tools live in process memory
# and are lost on restart). The addon sets this to /data/saved_tools.json
# by default so saved tools survive addon restarts (the /data directory
# is mapped per-addon by Supervisor and is preserved across addon
# updates).
code_mode_saved_tools_path: str = Field(
"", alias="CODE_MODE_SAVED_TOOLS_PATH"
)


@property
def env_file_name(self) -> str:
"""Get the current environment file name."""
Expand Down
8 changes: 8 additions & 0 deletions src/ha_mcp/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ class ErrorCode(StrEnum):
# Component errors
COMPONENT_NOT_INSTALLED = "COMPONENT_NOT_INSTALLED"

# Code-mode sandbox errors. The sandbox is a separate execution
# context; runtime failures inside it map cleanly to one of these
# three buckets so the LLM can self-recover instead of seeing every
# failure as INTERNAL_ERROR.
SANDBOX_LIMIT_EXCEEDED = "SANDBOX_LIMIT_EXCEEDED"
SANDBOX_SYNTAX_UNSUPPORTED = "SANDBOX_SYNTAX_UNSUPPORTED"
SANDBOX_RUNTIME_ERROR = "SANDBOX_RUNTIME_ERROR"


# Default suggestions for common error codes
DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
Expand Down
14 changes: 13 additions & 1 deletion src/ha_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,11 @@ def _apply_tool_search(self) -> None:
)
pinned.extend(getattr(self, "_skill_tool_names", []))

# Pin code mode tool so it gets individual permission gating
# rather than being hidden behind the BM25 search proxy.
if self.settings.enable_code_mode:
pinned.append("ha_manage_custom_tool")

# The client may not support resources or server instructions — add
# skills hint to the search tool description (the one place the LLM
# is guaranteed to see).
Expand All @@ -609,12 +614,19 @@ def _apply_tool_search(self) -> None:
max_results=self.settings.tool_search_max_results,
always_visible=pinned,
search_tool_description=description,
# Pinned tools must be excluded from the proxy's
# category sets when code mode is on; otherwise sandbox
# code can launder a recursive ``ha_manage_custom_tool``
# invocation through ``ha_call_write_tool``. See the
# docstring on ``_rebuild_category_cache``.
enable_code_mode=self.settings.enable_code_mode,
)
)
logger.info(
"Tool search transform applied (%d pinned tools, max_results=%d)",
"Tool search transform applied (%d pinned tools, max_results=%d, code_mode=%s)",
len(pinned),
self.settings.tool_search_max_results,
self.settings.enable_code_mode,
)
except Exception:
logger.exception("Failed to apply tool search transform")
Expand Down
Loading
Loading