Security
- Creating an automation that performs a critical action now requires the PIN.
add_automationwrote automation YAML and reloaded Home Assistant without ever passing the critical-action gate:_is_critical_actioninspects a tool call'sdomain/servicearguments, and this tool's payload is opaqueautomation_yaml. "Unlock the front door whenever I get home" therefore installed alock.unlockautomation with no PIN, while the direct command "unlock the front door" was gated — a durable bypass rather than a one-off action, since the installed automation keeps firing. Both adversarial reviewers converged on this independently during the v3.20.2 ship, where the automation-intent force-binding made the tool reliably reachable from everyday phrasings. With the PIN configured, a critical automation is now held as a pending action and the model is asked for the PIN exactly as it is for a direct unlock. Nothing touchesautomations.yamlor triggers a reload until the PIN is confirmed. Installs without a PIN are unaffected. - Screening is an allowlist over Home Assistant's own action taxonomy, not a list of blocked service names. A service call is not the only way an automation can unlock a door, and the first version of this gate — which only understood
action:/service:steps — was defeated by four verified bypasses during pre-landing review, each confirmed against a real Home Assistant install by two independent reviewers. Every step is now classified withcv.determine_script_actionand waved through only when it is provably inert or resolves to something no rule matches. Newly covered: device actions (device_id+domain+type), which carry no service name at all yet map straight tolock.unlock;scene.apply, which carries target states inline and reproduces anunlockedlock by callinglock.unlock(scene.createonly snapshots state, but the scene it stores can be activated later, so it is screened too); andhomeassistant.turn_on/turn_off/toggle, now screened against each target entity's own domain. An action type a future Home Assistant release adds is gated rather than skipped, so the screen degrades to over-prompting instead of to a hole. - Screening fails closed on anything it cannot resolve — a templated service name; a target that is an area, device, label, floor, group, or entity registry ID (both of which look like ordinary strings but resolve to entities named nowhere in the config);
entity_id: all; and indirection through a scene, script, another automation, or a fired event. Expect a PIN prompt when an automation calls a script or activates a scene, even a harmless one: what those do lives in configuration this check cannot see, and guessing is what the gate exists to avoid. Screening runs after Home Assistant validates the automation, so blueprints are substituted and bothservice:/action:spellings normalized, and every nested branch is walked at any depth. Notification action labels nested in a step'sdataare still not mistaken for service calls. cover.toggleandcover.set_cover_positionnow count as opening a door. The recommended critical actions listed onlyopen_coverandopen, but toggling a closed garage door opens it just as surely.lock.toggleandgarage_door.togglewere added for the same reason. This closes the hole for direct commands as well as automations, so a household with the PIN enabled may now see a prompt for a phrasing that previously went through silently.- Device actions are screened on their domain, not on their
typestring. A device action'stypeis not the service it calls — each integration maps it in its own code, andcoverturnsset_positionintocover.set_cover_position. Matching the raw type therefore only worked for locks, where the two names coincide by accident, leaving every cover device action open; atype: set_positionwithposition: 100opened a garage door with no PIN. A device action on a domain any rule guards is now gated whenever its type does not match a rule outright. This also covers user-added rules for domains this project ships no defaults for. - A resolvable target no longer masks an unresolvable one beside it.
homeassistant.toggleaimed at[light.kitchen, <lock registry ID>]screened only the light and passed. Failing closed on the unresolvable remainder is now scoped by service name, so an area-widehomeassistant.turn_on— an everyday automation — still does not prompt. - The generic domain cannot be used to reach a script or scene.
homeassistant.turn_onaimed atscript.unlock_front_doorforwards toscript.turn_onand runs it, but ascript.*target was treated as merely unresolvable, and unresolvable targets were gated only when the service name itself was guarded — no rule namesturn_on. Targeting a script, scene, or automation entity is now indirection whatever the service is, matching how a directscript.turn_oncall is already treated. - Services that run stored configuration are gated:
python_script(whose sandbox permitshass.services.call),shell_command,rest_command,button.press/input_button.press(a template button's press field is a full script), andconversation.process(free text dispatches an intent, and anintent_scriptcan unlock a door). Each has a device-action spelling as well, which is gated the same way. - Any generic
homeassistant.*call whose targets are not all named entities is gated. An earlier iteration only gated unresolvable targets when the service name itself was guarded, to avoid prompting on an area-widehomeassistant.turn_on. That was unsound: the generic domain forwards by resolved domain, and an area, group, or registry ID can resolve to a script or to a template entity whoseturn_onruns one. Screening happens before the automation is written and has no entity registry to consult, so it now asks rather than guesses.data_template, still accepted by Home Assistant, is inspected for targets alongsidetargetanddata. - An automation cannot be installed while the PIN is enabled but unset. The direct-command guard allows a call in that state with a logged warning; this path refuses instead and tells the user to set a PIN, because an automation persists and keeps firing rather than acting once.
- The rule-matching semantics behind both gates live in one shared helper so the direct-command guard and the automation screen cannot drift apart. The test that pins them now asserts an absolute expected verdict on each side rather than merely that the two agree — the earlier version stayed green with the shared matcher stubbed out entirely.
Fixed
- A confirmed automation is claimed before it is written, so two confirmations of the same action in one concurrent tool batch cannot install it twice.
- Abandoned PIN challenges no longer accumulate. Expired confirmations were only ever dropped when that exact action was later confirmed, so a challenge the model never resolved was retained for the life of the config entry — and once more than one piled up, the single-pending-action convenience path stopped resolving. Registering a confirmation now sweeps expired entries and caps the store.
- Documentation corrected: the Critical Action PIN section stated that enabling the guard without setting a PIN causes the agent to reject requests. For direct commands it does not — it logs a warning and allows the action. The docs now describe the actual behavior of each path.
Known limitations
- Raw protocol writes are not screened. Screening matches domains and services, so a call that addresses a device beneath the entity layer —
mqtt.publishto a lock's command topic,zwave_js.set_valuewriting a Door Lock command class,zha.issue_zigbee_cluster_command— can reach a lock without naming thelockdomain. These are deliberately not gated by default because they are routine on those stacks;docs/configuration.mddocuments a per-transport rule you can add tocritical_actionsif you run locks on one of them. - Targets that resolve at run time cannot be inspected. Screening happens before the automation is written and has no entity registry, so a group, area, or registry ID cannot be expanded and a template entity cannot be told apart from an ordinary one. Unresolvable targets are gated rather than reasoned about, which is why some automations prompt more than a human reader would expect.
- A blueprint-based automation is stored as a
use_blueprint:reference and re-substituted by Home Assistant on every reload, so the PIN attests to what the blueprint did at approval time. Editing that blueprint file afterwards changes what the approved automation runs without a fresh prompt. Plain YAML automations are written exactly as screened.