Skip to content

Latest commit

 

History

History
239 lines (204 loc) · 13 KB

SECURITY.md

File metadata and controls

239 lines (204 loc) · 13 KB

Security

This integration exposes tools that can read and write your Home Assistant configuration - automations, dashboards, helpers, arbitrary files within an allowlist, log contents, Supervisor add-on logs. That's a lot more power than a typical integration, and it deserves a threat model spelled out explicitly rather than assumed. Everything below was verified by reading the actual Home Assistant and mcp_server source, not assumed from how a "normal" integration behaves.

Why this needed more than "require admin"

Home Assistant's own token model has no concept of a scoped credential. homeassistant/auth/models.py's RefreshToken - the thing every access token, whether a mobile app's, a browser session's, or a long-lived token pasted into some other script years ago, ultimately derives from - carries no scope field at all. Whatever user.is_admin resolves to for a given user, every single token that user holds is exactly that powerful. Home Assistant has no native way to mint a credential that can do less than everything that user's account can do.

Under a stock Home Assistant install, that's usually fine, because the ceiling of "everything an admin account can do" through the web/API layer is itself bounded: entity control, service calls, config UI edits. Raw file access - the kind that lets you write an automation with a shell_command or rest_command action, i.e. a path to arbitrary execution - normally sits behind a separate credential domain entirely: SSH, the Terminal add-on, or Samba, none of which share any identity system with a Home Assistant user account. Compromising someone's HA login has never meant compromising their SSH access, and vice versa.

This integration collapses that separation. Reachable over MCP, dev_tools puts file read/write and the shell_command/rest_command-via-automation path behind the same credential as normal Home Assistant API access. Requiring is_admin alone doesn't restore the separation - it just means the credential that already grants this access is the same one embedded in that admin's phone app, browser session, and any other long-lived token they've ever created and forgotten about. None of those were ever managed with "this is as sensitive as an SSH key" in mind.

There's also a narrower, more mechanical gap: mcp_server's own admin check (homeassistant/components/mcp_server/http.py) only covers the explicit /api/mcp/<api_id> URL. The bare /api/mcp endpoint serves whatever APIs a mcp_server config entry lists, with no admin check at all - just ordinary Home Assistant authentication. If dev_tools ever ends up in that entry's default API list (a plain multi-select in mcp_server's own config flow, with no warning distinguishing a narrow API like Assist from a broad one like this), any authenticated non-admin account - a restricted household member, a guest - reaches it through that endpoint with the admin check bypassed entirely.

The two gates

Every tool except the diagnostic dev_tools_ping requires both of the following before it does anything (custom_components/ha_dev_tools/access_control.py, enforced by a shared GatedTool base class so no individual tool can accidentally skip it):

1. Proof of recent out-of-band access (the "arm file")

A specific file - by default /config/.storage/ha_dev_tools.armed - must exist and be recent. This integration is denylisted from ever writing that file's content itself (see const.py's DEFAULT_DENYLIST), and the code path that checks it never routes through the general file-write machinery at all. Only something with real filesystem access - SSH, the Terminal add-on, Samba - can create it. This restores the separate-credential-domain property described above: a leaked ordinary Home Assistant token, on its own, cannot arm dev_tools.

Arming:

date +%s > /config/.storage/ha_dev_tools.armed

How long it stays armed:

  • Up to 30 minutes idle. Every successful tool call bumps the file's modification time, extending the window - this is what lets a real working session continue without you re-arming by hand every half hour.
  • A hard cap of 4 hours from the moment it was first created, regardless of activity. This is read from the file's content (the timestamp you wrote when arming it), which dev_tools never rewrites - only its mtime is touched on each use. If dev_tools could rewrite the content too, a busy session could reset its own ceiling indefinitely; keeping that one field permanently outside its own reach is what makes the cap real.
  • Both limits are settings on the integration's Configure page, within fixed bounds: the idle window 5 minutes - 8 hours, the hard cap 15 minutes - 24 hours, and the idle window never above the cap. There's no "never expire": an arm always ends on its own, at most a day after it was created. The settings are read on every check, so shortening them applies to an active arm at once.

A background task removes an expired arm file on a 5-minute cycle, purely for tidiness. It is never what enforces expiry - every gated call re-derives armed/expired state directly from the file on disk, so a missed cleanup tick (a Home Assistant restart, for instance) can never leave dev_tools armed longer than intended.

On Home Assistant OS/Supervised, the homeassistant container's filesystem is a constrained bind-mount - a location genuinely outside /config, reachable only via host-level SSH, would be unreachable from inside this integration's own process even under a worst-case scenario (a malicious automation achieving code execution inside the container). That would be true credential-domain separation, matching SSH exactly. On a bare Core/venv/Docker install, where "this process" and "SSH into the box" are the same OS user, the separation is weaker - but the deliberate arm/disarm ritual and the time-bounding still apply.

2. A genuine admin account

The calling user - resolved from the MCP request's real authenticated context, never a synthetic bypass - must have is_admin set. This is checked independently of mcp_server's own gate, closing the bare-/api/mcp endpoint gap described above regardless of how mcp_server happens to be configured.

Path allowlist (file-touching tools)

Tools that touch files on disk (write_automation, and the underlying FileManager more generally) are further bounded by SecurityManager's allowlist/denylist of paths - independent of, and in addition to, the two gates above. Sensitive files (secrets.yaml, auth storage, the arm file itself) are permanently denylisted no matter what.

A path with a .. component is refused outright, even when it would stay inside the config folder, and the denylist is matched against where a path really leads as well as its name, so a symlink can't lead around it. Containment in the config folder is a path check, not a string prefix.

Every tool's arguments are checked against its declared schema before it runs: Home Assistant itself passes an MCP client's JSON to a tool unchecked. And no tool argument can choose the WebSocket command a tool runs. A helper's config once could, by carrying a type key, which ran any command as the calling admin, up to minting a long-lived access token.

There's currently no way to customize this - it's defaults-only (no configuration.yaml option, no UI options flow yet). The defaults (custom_components/ha_dev_tools/const.py):

  • Readable: main config files (configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml), packages/**/*.yaml, custom_templates/**/*.jinja, and the storage files behind dashboards/helpers/scenes/scripts. get_config_file and config snapshots also cover every file a readable config file !includes: Home Assistant reads an included file as part of the file that includes it, so it's covered by that file's read permission (the denylist still applies).
  • Writable: exactly what write_automation needs and nothing more - automations.yaml and packages/**/*.yaml.
  • Always denied: secrets.yaml (in any folder - Home Assistant also reads one from a subfolder such as packages/), all auth/credential storage, the arm file itself, and a handful of other sensitive system files - regardless of what read/write defaults say.

get_config_file returns a file's raw text, so it adds one more rule: a file is withheld entirely if any line of it looks like a literal credential (a credential-shaped key with a value that isn't a !secret/!env_var reference, a password in a URL, a bearer token), including in comments and inside strings such as a JSON payload. The response names the line and key, never the value. Config snapshots to the mirror repo use the same check.

An earlier version of this project had a configuration.yaml-based ha_dev_tools: security: block for customizing this - it was removed because it never actually reached the tools that use it (a config-entry plumbing bug, not by design), and even working as intended, it left write_paths empty by default, meaning the write tools never functioned out of the box. Fixed by giving write_paths a real default instead of trying to repair the broken YAML bridge. If per-install customization is ever needed, it belongs in a proper options flow (Settings → Devices & Services → HA Dev Tools → Configure), not YAML.

Scoped service-call tools

trigger_automation, set_number_value, set_boolean_value and control_entities (service_call_manager.py) call Home Assistant services - a different surface than the path allowlist above, which only governs file-touching tools. A generic call_service tool (domain + service + arbitrary data) was considered and rejected: it would be able to reach any registered service, including high-consequence ones on an instance with real physical devices (lock.unlock, alarm_control_panel.disarm, backup.*, homeassistant.restart), and bounding that with a configurable allow/deny list just moves the same risk into a config value someone has to get right.

Instead, each of these tools wraps exactly one hardcoded service call, so the tool surface itself is the allowlist rather than something configured on top of a broad one:

  • trigger_automation only calls automation.trigger, and only for an automation id that already resolves to a live entity - the security boundary is "already in reviewed automations.yaml/packages", the same boundary write_automation already draws, not "whatever entity_id the caller passes".
  • set_number_value/set_boolean_value only reach the number/ input_number and input_boolean domains respectively - refusing every other domain outright. In particular, switch (which could be anything from a desk lamp to a garage door opener) and any domain that maps to a real-world actuator were deliberately left out of these two; they got their own review as control_entities, below.

control_entities (2026-10-22) is the owner's decision to open the actuator domains after all, still without a passthrough: on / off / toggle for switch, light, fan, input_boolean, climate, media_player, humidifier, cover and valve, each action mapped to that domain's own hardcoded service (turn_on/turn_off/toggle, open_cover/ close_cover, open_valve/close_valve), plus brightness_pct for a light. No other service or service data can be reached through it.

  • lock, alarm_control_panel and siren are never controllable - they aren't in the mapping, and are refused by name.
  • Devices and entities listed on the Configure page ("Devices/Entities agents may never control") are refused - every entity of an excluded device, whatever it is. That's where a garage door opener or a door's relay switch goes.
  • A refusal comes before any confirm token, listing every reason, so a call that can't go ahead is never put to the user to confirm.
  • The preview names each entity, its area, its current state and the exact service call, so the user confirms what will actually move.

All four still go through the same two GatedTool checks (arm file + admin) as every other tool, plus the same WriteGatedTool propose/confirm gate as write_automation - this scoping is in addition to those, not a replacement for them.

What this does not do

  • It does not sandbox this integration's own code. Home Assistant gives custom integrations no process isolation - ha_dev_tools runs with the same privileges as Home Assistant itself, same as any other integration. The gates above control when and by whom the tools can be invoked, not what they're capable of once invoked.
  • It does not encrypt or specially protect the arm file's timestamp content - it's not a secret, it's a proof-of-recent-physical-access marker. Anyone who can read /config/.storage/ can see it exists; that's fine, since reading it grants nothing.
  • It does not replace normal Home Assistant hardening: keep this instance off the open internet, use a real password/MFA on admin accounts, and don't add dev_tools to mcp_server's default API list unless you've read the section above and understand what that means.