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.
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.
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):
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.armedHow 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_toolsnever rewrites - only its mtime is touched on each use. Ifdev_toolscould 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.
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.
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_fileand 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_automationneeds and nothing more -automations.yamlandpackages/**/*.yaml. - Always denied:
secrets.yaml(in any folder - Home Assistant also reads one from a subfolder such aspackages/), 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.
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_automationonly callsautomation.trigger, and only for an automation id that already resolves to a live entity - the security boundary is "already in reviewedautomations.yaml/packages", the same boundarywrite_automationalready draws, not "whatever entity_id the caller passes".set_number_value/set_boolean_valueonly reach thenumber/input_numberandinput_booleandomains 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 ascontrol_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_panelandsirenare 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.
- It does not sandbox this integration's own code. Home Assistant gives
custom integrations no process isolation -
ha_dev_toolsruns 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_toolstomcp_server's default API list unless you've read the section above and understand what that means.