ha-mcp-dev 7.14.2.dev2107__tar.gz → 7.14.2.dev2109__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. {ha_mcp_dev-7.14.2.dev2107/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.14.2.dev2109}/PKG-INFO +1 -1
  2. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/pyproject.toml +1 -1
  3. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/errors.py +8 -0
  4. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/llm_exposure.py +67 -6
  5. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/handlers.py +24 -18
  6. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/middleware.py +24 -1
  7. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/model.py +11 -0
  8. ha_mcp_dev-7.14.2.dev2109/src/ha_mcp/policy/persistence.py +125 -0
  9. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/server.py +70 -1
  10. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_backups.py +76 -42
  11. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_tools.py +59 -52
  12. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_persistence.py +13 -6
  13. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/de.json +7 -2
  14. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/en.json +7 -2
  15. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/ru.json +7 -2
  16. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/zh-Hans.json +4 -1
  17. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/settings.html +10 -3
  18. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/settings.js +146 -48
  19. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/registry.py +6 -0
  20. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_deep.py +26 -0
  21. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_dev.py +1023 -101
  22. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_search.py +40 -1
  23. ha_mcp_dev-7.14.2.dev2109/src/ha_mcp/utils/config_write_lock.py +130 -0
  24. ha_mcp_dev-7.14.2.dev2109/src/ha_mcp/visibility/enforcement.py +590 -0
  25. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/model.py +7 -0
  26. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/resolver.py +94 -18
  27. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
  28. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/SOURCES.txt +2 -0
  29. ha_mcp_dev-7.14.2.dev2107/src/ha_mcp/policy/persistence.py +0 -43
  30. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/LICENSE +0 -0
  31. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/MANIFEST.in +0 -0
  32. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/README.md +0 -0
  33. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/setup.cfg +0 -0
  34. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/__init__.py +0 -0
  35. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/__main__.py +0 -0
  36. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/_pypi_marker +0 -0
  37. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/_version.py +0 -0
  38. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/auth/__init__.py +0 -0
  39. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/auth/consent_form.py +0 -0
  40. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/auth/provider.py +0 -0
  41. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/backup_manager.py +0 -0
  42. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/browser_landing.py +0 -0
  43. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/__init__.py +0 -0
  44. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/rest_client.py +0 -0
  45. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/supervisor_client.py +0 -0
  46. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/websocket_client.py +0 -0
  47. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/websocket_listener.py +0 -0
  48. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/config.py +0 -0
  49. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
  50. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
  51. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
  52. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
  53. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
  54. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
  55. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/__init__.py +0 -0
  56. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/approval_queue.py +0 -0
  57. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/evaluator.py +0 -0
  58. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/value_sources.py +0 -0
  59. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/py.typed +0 -0
  60. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/read_only.py +0 -0
  61. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
  62. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
  63. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
  64. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
  65. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
  66. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  67. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  68. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  69. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  70. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  71. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
  72. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
  73. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/appdaemon.md +0 -0
  74. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
  75. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/blueprint-guide.md +0 -0
  76. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
  77. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
  78. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
  79. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
  80. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
  81. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
  82. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
  83. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/scenes.md +0 -0
  84. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
  85. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
  86. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/__init__.py +0 -0
  87. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
  88. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
  89. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
  90. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
  91. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_i18n.py +0 -0
  92. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
  93. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_theme.py +0 -0
  94. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
  95. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/README.md +0 -0
  96. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/settings.css +0 -0
  97. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/smoke_test.py +0 -0
  98. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
  99. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/strict_bps.py +0 -0
  100. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/__init__.py +0 -0
  101. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/auto_backup.py +0 -0
  102. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/backup.py +0 -0
  103. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  104. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_api.py +0 -0
  105. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_config_reads.py +0 -0
  106. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_devices.py +0 -0
  107. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_registries.py +0 -0
  108. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
  109. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/config_entry_flow.py +0 -0
  110. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/device_control.py +0 -0
  111. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/enhanced.py +0 -0
  112. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/helpers.py +0 -0
  113. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/__init__.py +0 -0
  114. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/base.py +0 -0
  115. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/matter.py +0 -0
  116. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/thread.py +0 -0
  117. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/zigbee.py +0 -0
  118. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/zwave.py +0 -0
  119. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/reference_validator.py +0 -0
  120. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
  121. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_base.py +0 -0
  122. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_config.py +0 -0
  123. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
  124. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
  125. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
  126. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
  127. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
  128. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tool_search_hint_middleware.py +0 -0
  129. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_addons.py +0 -0
  130. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_areas.py +0 -0
  131. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  132. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  133. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_calendar.py +0 -0
  134. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_camera.py +0 -0
  135. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_categories.py +0 -0
  136. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_code.py +0 -0
  137. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  138. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  139. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  140. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  141. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  142. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
  143. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_energy.py +0 -0
  144. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_entities.py +0 -0
  145. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  146. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_groups.py +0 -0
  147. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_hacs.py +0 -0
  148. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_history.py +0 -0
  149. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_integrations.py +0 -0
  150. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_labels.py +0 -0
  151. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_radio.py +0 -0
  152. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_registry.py +0 -0
  153. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_resources.py +0 -0
  154. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_service.py +0 -0
  155. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_services.py +0 -0
  156. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_system.py +0 -0
  157. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_themes.py +0 -0
  158. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_todo.py +0 -0
  159. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_traces.py +0 -0
  160. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_updates.py +0 -0
  161. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_utility.py +0 -0
  162. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  163. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  164. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
  165. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_zones.py +0 -0
  166. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/util_helpers.py +0 -0
  167. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/validation_middleware.py +0 -0
  168. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transforms/__init__.py +0 -0
  169. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transforms/categorized_search.py +0 -0
  170. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  171. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transport_security.py +0 -0
  172. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/update_check.py +0 -0
  173. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/__init__.py +0 -0
  174. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/config_hash.py +0 -0
  175. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/data_paths.py +0 -0
  176. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/domain_handlers.py +0 -0
  177. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  178. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  179. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/operation_manager.py +0 -0
  180. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/python_sandbox.py +0 -0
  181. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/skill_loader.py +0 -0
  182. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/usage_logger.py +0 -0
  183. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/__init__.py +0 -0
  184. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/persistence.py +0 -0
  185. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
  186. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
  187. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  188. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
  189. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/tests/__init__.py +0 -0
  190. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/tests/test_constants.py +0 -0
  191. {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/tests/test_env_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-mcp-dev
3
- Version: 7.14.2.dev2107
3
+ Version: 7.14.2.dev2109
4
4
  Summary: Home Assistant MCP Server - Complete control of Home Assistant through MCP
5
5
  Author-email: Julien <github@qc-h.net>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-mcp-dev"
7
- version = "7.14.2.dev2107"
7
+ version = "7.14.2.dev2109"
8
8
  description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
9
9
  readme = "README.md"
10
10
  # Upper bound widened to <3.15 so the package installs on Home Assistant's
@@ -116,6 +116,14 @@ class ErrorCode(StrEnum):
116
116
  # backup directory is unusable). Fail-closed: nothing was changed.
117
117
  BACKUP_CAPTURE_FAILED = "BACKUP_CAPTURE_FAILED"
118
118
 
119
+ # Entity visibility enforce mode (#2015). A read was refused because the
120
+ # opt-in visibility filter's enforce mode is active and the request would
121
+ # surface an entity the filter hides — either the tool's output references a
122
+ # hidden entity, or the tool's output cannot be scanned (screenshots, sandbox
123
+ # code). Direct reads of a hidden entity are concealed as ENTITY_NOT_FOUND
124
+ # instead; this code marks the refuse-on-contact case. No data was returned.
125
+ ENTITY_VISIBILITY_ENFORCED = "ENTITY_VISIBILITY_ENFORCED"
126
+
119
127
 
120
128
  # Default suggestions for common error codes
121
129
  DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
@@ -14,9 +14,10 @@ axis from the global enable/disable in the settings UI:
14
14
 
15
15
  The single source of truth travels **in-band**: :class:`LlmExposureMiddleware`
16
16
  stamps every ``tools/list`` entry with
17
- ``_meta.ha_mcp = {"llm_api_exposed": bool, "pinned": bool}`` so the component
18
- (one more loopback MCP client) filters on data that can never drift from the
19
- server's settings, with zero extra round-trips. Stamping re-reads the
17
+ ``_meta.ha_mcp = {"llm_api_exposed": bool, "pinned": bool, "policy": {...}}``
18
+ so the component (one more loopback MCP client) filters on data that can never
19
+ drift from the server's settings, with zero extra round-trips. The ``policy``
20
+ block reports the serving server's gating state (#1990 — see META_POLICY_KEY). Stamping re-reads the
20
21
  persisted settings behind a short coalescing cache (2s TTL), so settings-UI
21
22
  changes apply on the agent's next conversation turn without a restart.
22
23
 
@@ -31,7 +32,7 @@ from __future__ import annotations
31
32
 
32
33
  import logging
33
34
  import time
34
- from collections.abc import Mapping, Sequence
35
+ from collections.abc import Callable, Mapping, Sequence
35
36
  from typing import TYPE_CHECKING, Any
36
37
 
37
38
  from fastmcp.server.middleware import Middleware
@@ -50,6 +51,15 @@ META_NAMESPACE = "ha_mcp"
50
51
  META_EXPOSED_KEY = "llm_api_exposed"
51
52
  META_PINNED_KEY = "pinned"
52
53
 
54
+ # Serving-server policy/identity block stamped alongside the per-tool keys
55
+ # (#1990). A client (or a debugging agent) reading tools/list can see the
56
+ # ACTUAL gating state of the server answering this connection — configured
57
+ # flag, live middleware, and rule count — plus the deployment mode. Without
58
+ # this, a client pointed at a different server than the one the user
59
+ # configured rules on fails silently: calls execute ungated and nothing
60
+ # anywhere says "this server has zero rules".
61
+ META_POLICY_KEY = "policy"
62
+
53
63
  # Key in tool_config.json holding the user's per-tool overrides
54
64
  # ({tool_name: bool}). Sparse on purpose: only tools the user explicitly
55
65
  # flipped are stored, so tools added by future releases keep getting their
@@ -153,9 +163,17 @@ class LlmExposureMiddleware(Middleware):
153
163
  is hidden or altered for regular MCP clients.
154
164
  """
155
165
 
156
- def __init__(self) -> None:
157
- """Initialize the short-lived settings cache."""
166
+ def __init__(self, policy_live: Callable[[], bool] | None = None) -> None:
167
+ """Initialize the short-lived settings cache.
168
+
169
+ ``policy_live`` reports whether the gating middleware/queue are
170
+ actually wired on this server (they only wire at startup); the
171
+ stamp carries it so "configured but not enforcing until restart"
172
+ is visible on the wire.
173
+ """
158
174
  self._cache: tuple[float, dict[str, bool], set[str]] | None = None
175
+ self._policy_live = policy_live
176
+ self._policy_cache: tuple[float, dict[str, Any]] | None = None
159
177
 
160
178
  def _current_settings(self) -> tuple[dict[str, bool], set[str]]:
161
179
  """Return (overrides, pinned) with a short TTL over the file reads."""
@@ -192,6 +210,47 @@ class LlmExposureMiddleware(Middleware):
192
210
  self._cache = (now, overrides, pinned)
193
211
  return overrides, pinned
194
212
 
213
+ def _policy_block(self) -> dict[str, Any]:
214
+ """Serving-server policy/identity block (TTL-cached like the overrides).
215
+
216
+ Best-effort: a failure to read settings or the policy file stamps
217
+ conservative values (enabled=False / rules=0) rather than breaking
218
+ tools/list — the block is diagnostic metadata, not enforcement.
219
+ """
220
+ now = time.monotonic()
221
+ if (
222
+ self._policy_cache is not None
223
+ and now - self._policy_cache[0] < _OVERRIDES_TTL_SECONDS
224
+ ):
225
+ return self._policy_cache[1]
226
+ from ._version import is_embedded, is_running_in_addon
227
+
228
+ block: dict[str, Any] = {
229
+ "enabled": False,
230
+ "live": bool(self._policy_live()) if self._policy_live else False,
231
+ "rules": 0,
232
+ "deployment": (
233
+ "embedded"
234
+ if is_embedded()
235
+ else ("addon" if is_running_in_addon() else "standalone")
236
+ ),
237
+ }
238
+ try:
239
+ from .config import get_global_settings
240
+
241
+ block["enabled"] = bool(get_global_settings().enable_tool_security_policies)
242
+ except Exception:
243
+ logger.debug("policy stamp: settings read failed", exc_info=True)
244
+ try:
245
+ from .policy.persistence import load_policy
246
+ from .utils.data_paths import get_data_dir
247
+
248
+ block["rules"] = len(load_policy(get_data_dir()).rules)
249
+ except Exception:
250
+ logger.debug("policy stamp: policy read failed", exc_info=True)
251
+ self._policy_cache = (now, block)
252
+ return block
253
+
195
254
  async def on_list_tools(
196
255
  self,
197
256
  context: MiddlewareContext[mt.ListToolsRequest],
@@ -200,6 +259,7 @@ class LlmExposureMiddleware(Middleware):
200
259
  """Stamp ``_meta.ha_mcp`` on every tool in the list result."""
201
260
  tools = await call_next(context)
202
261
  overrides, pinned = self._current_settings()
262
+ policy_block = self._policy_block()
203
263
 
204
264
  stamped: list[Tool] = []
205
265
  for tool in tools:
@@ -209,6 +269,7 @@ class LlmExposureMiddleware(Middleware):
209
269
  tool.name, tool.tags or set(), overrides
210
270
  )
211
271
  namespace[META_PINNED_KEY] = tool.name in pinned
272
+ namespace[META_POLICY_KEY] = policy_block
212
273
  meta[META_NAMESPACE] = namespace
213
274
  stamped.append(tool.model_copy(update={"meta": meta}))
214
275
  return stamped
@@ -11,6 +11,7 @@ from pydantic import ValidationError
11
11
  from starlette.requests import Request
12
12
  from starlette.responses import JSONResponse
13
13
 
14
+ from ..utils.config_write_lock import config_write_guard
14
15
  from .approval_queue import ApprovalQueue
15
16
  from .model import Policy
16
17
  from .persistence import load_policy, save_policy
@@ -86,24 +87,29 @@ async def _put_config(
86
87
  # Optimistic concurrency: reject if the on-disk version moved
87
88
  # between this caller's GET and PUT. Returns the current policy
88
89
  # so the client can rebase if it wants to retry.
89
- current = load_policy(data_dir)
90
- if new_policy.version != current.version:
91
- return JSONResponse(
92
- {
93
- "error": "policy version mismatch — reload before saving",
94
- "current_version": current.version,
95
- "current_policy": current.model_dump(mode="json"),
96
- },
97
- status_code=409,
98
- )
99
- save_policy(data_dir, new_policy)
100
- # Drop the remember-cache only when rules actually changed.
101
- # Editing just wait_seconds / approval_ttl_minutes shouldn't
102
- # invalidate in-flight remembered approvals; only a rule change
103
- # could make a previously-approved call now want a different
104
- # outcome.
105
- if current.rules != new_policy.rules:
106
- queue.clear_remember_cache()
90
+ # Serialize the version-check + save against the developer tool
91
+ # (set_policy / set_tool) AND against other processes (the stdio
92
+ # sidecar runs this same handler in its own process) so a concurrent
93
+ # writer can't slip between the read and the write and lose an update.
94
+ async with config_write_guard():
95
+ current = load_policy(data_dir)
96
+ if new_policy.version != current.version:
97
+ return JSONResponse(
98
+ {
99
+ "error": "policy version mismatch — reload before saving",
100
+ "current_version": current.version,
101
+ "current_policy": current.model_dump(mode="json"),
102
+ },
103
+ status_code=409,
104
+ )
105
+ save_policy(data_dir, new_policy)
106
+ # Drop the remember-cache only when rules actually changed.
107
+ # Editing just wait_seconds / approval_ttl_minutes shouldn't
108
+ # invalidate in-flight remembered approvals; only a rule change
109
+ # could make a previously-approved call now want a different
110
+ # outcome.
111
+ if current.rules != new_policy.rules:
112
+ queue.clear_remember_cache()
107
113
  return JSONResponse({"saved": True, "version": new_policy.version + 1})
108
114
 
109
115
 
@@ -35,6 +35,29 @@ PROXY_META_TOOLS = frozenset(
35
35
  }
36
36
  )
37
37
 
38
+ # ha_dev_manage_server actions that MANAGE the approval queue itself.
39
+ # Gating these deadlocks by construction: with a wildcard (or
40
+ # ha_dev_manage_server) rule in place, an MCP-only "approve" call would
41
+ # itself require approval — creating a second pending entry instead of
42
+ # deciding the first, so nothing can ever be approved through the tool.
43
+ # Only the queue-management actions are exempt; update_source / restart
44
+ # remain gateable like any other high-stakes action.
45
+ _APPROVAL_MANAGEMENT_TOOL = "ha_dev_manage_server"
46
+ _APPROVAL_MANAGEMENT_ACTIONS = frozenset({"list_pending", "approve", "deny"})
47
+
48
+
49
+ def _is_approval_management(name: str, args: dict[str, Any]) -> bool:
50
+ """True for dev-tool calls that manage the approval queue itself."""
51
+ return (
52
+ name == _APPROVAL_MANAGEMENT_TOOL
53
+ and args.get("action") in _APPROVAL_MANAGEMENT_ACTIONS
54
+ )
55
+
56
+
57
+ def _passes_ungated(name: str, args: dict[str, Any]) -> bool:
58
+ """Calls that must bypass gating: proxy meta-tools + queue management."""
59
+ return name in PROXY_META_TOOLS or _is_approval_management(name, args)
60
+
38
61
 
39
62
  class PolicyMiddleware(Middleware):
40
63
  """Gate tool calls against a Policy, blocking with progress heartbeats."""
@@ -78,7 +101,7 @@ class PolicyMiddleware(Middleware):
78
101
  name = context.message.name
79
102
  args = context.message.arguments or {}
80
103
 
81
- if name in PROXY_META_TOOLS:
104
+ if _passes_ungated(name, args):
82
105
  return await call_next(context)
83
106
 
84
107
  if evaluate(name, args, policy) != Verdict.REQUIRE_APPROVAL:
@@ -16,6 +16,14 @@ PredicateOp = Literal[
16
16
  "eq", "neq", "in", "not_in", "regex", "contains", "exists", "gt", "lt"
17
17
  ]
18
18
 
19
+ # Schema generation of the persisted policy file. Version 2 = ANY-match
20
+ # condition semantics (PR #1993): each UI condition is its own rule; a rule's
21
+ # predicates AND together (a condition with sub-parameters). Files WITHOUT the
22
+ # marker were written under the pre-#1993 editor, which packed every condition
23
+ # into one AND-ed rule — ``persistence.migrate_policy_any_semantics`` splits
24
+ # those once at startup and stamps the file.
25
+ POLICY_SCHEMA_VERSION = 2
26
+
19
27
 
20
28
  class Predicate(BaseModel):
21
29
  """Single condition on a tool call's arguments (e.g. args.domain in [...])."""
@@ -103,6 +111,9 @@ class Policy(BaseModel):
103
111
  approval_ttl_minutes: int = Field(default=5, ge=1, le=60)
104
112
  rules: list[Rule] = Field(default_factory=list)
105
113
  version: int = Field(default=0, ge=0)
114
+ # ANY-match schema marker (see POLICY_SCHEMA_VERSION). Detection of
115
+ # unmigrated files reads the RAW json (this default would mask it).
116
+ schema_version: int = Field(default=POLICY_SCHEMA_VERSION, ge=1)
106
117
 
107
118
  @model_validator(mode="after")
108
119
  def _wait_must_be_less_than_ttl(self) -> "Policy":
@@ -0,0 +1,125 @@
1
+ """Atomic load/save for tool_policy.json (mirrors tool_config.json pattern in settings_ui/__init__.py)."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from pydantic import ValidationError
10
+
11
+ from .model import Policy, Rule
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ POLICY_FILENAME = "tool_policy.json"
16
+
17
+
18
+ def load_policy(data_dir: Path) -> Policy:
19
+ path = data_dir / POLICY_FILENAME
20
+ if not path.exists():
21
+ return Policy()
22
+ try:
23
+ raw = json.loads(path.read_text(encoding="utf-8"))
24
+ except json.JSONDecodeError as e:
25
+ raise ValueError(f"tool_policy.json is not valid JSON: {e}") from e
26
+ try:
27
+ return Policy.model_validate(raw)
28
+ except ValidationError as e:
29
+ raise ValueError(f"tool_policy.json failed schema validation: {e}") from e
30
+
31
+
32
+ def migrate_policy_any_semantics(data_dir: Path) -> bool:
33
+ """One-time migration of a pre-ANY policy file (PR #1993). Returns True on write.
34
+
35
+ The pre-#1993 editor packed every UI condition into ONE rule with the
36
+ predicates AND-ed ("require approval when ALL conditions match"). The
37
+ editor now writes one rule per condition and the UI reads "ANY condition
38
+ matches" — so an untouched old file would silently enforce AND while the
39
+ UI claims ANY. This splits every multi-predicate rule of an UNSTAMPED
40
+ file into one single-predicate rule each (the explicit, documented
41
+ breaking change: old AND conditions become OR, the more-restrictive
42
+ direction) and stamps ``schema_version`` so the migration never re-runs —
43
+ post-upgrade multi-predicate rules (a condition with AND-ed
44
+ sub-parameters, hand-authored or via future UI) are left intact.
45
+
46
+ Detection reads the RAW json: the Policy model defaults
47
+ ``schema_version`` to current, which would mask an unstamped file.
48
+ Missing or corrupt files are left alone (load_policy surfaces corruption).
49
+
50
+ The whole read-split-save runs under ``config_file_lock()`` so a
51
+ concurrent writer in another process (the stdio settings sidecar) can't
52
+ interleave with the migration's read-modify-write. The blocking lock is
53
+ taken inline: this runs once during server construction, before anything
54
+ is served. The sync entry points construct pre-loop; OAuth/OIDC construct
55
+ on a just-started ``asyncio.run`` loop (``_run_oauth_server`` /
56
+ ``_run_oidc_server``) where the sync constructor cannot await — but no
57
+ other task is scheduled on that loop yet, so a held lock can only delay
58
+ startup, never stall a served client.
59
+ """
60
+ from ..utils.config_write_lock import config_file_lock
61
+
62
+ with config_file_lock(data_dir):
63
+ return _migrate_policy_any_semantics_locked(data_dir)
64
+
65
+
66
+ def _migrate_policy_any_semantics_locked(data_dir: Path) -> bool:
67
+ path = data_dir / POLICY_FILENAME
68
+ try:
69
+ raw = json.loads(path.read_text(encoding="utf-8"))
70
+ except FileNotFoundError:
71
+ return False
72
+ except (OSError, json.JSONDecodeError):
73
+ logger.warning("policy migration: cannot read %s; leaving as-is", path)
74
+ return False
75
+ if not isinstance(raw, dict) or raw.get("schema_version") is not None:
76
+ return False
77
+ try:
78
+ policy = Policy.model_validate(raw)
79
+ except ValidationError:
80
+ logger.warning("policy migration: %s failed validation; leaving as-is", path)
81
+ return False
82
+ new_rules: list[Rule] = []
83
+ split = 0
84
+ for rule in policy.rules:
85
+ if len(rule.when) > 1:
86
+ split += 1
87
+ new_rules.extend(
88
+ Rule(
89
+ tool_name=rule.tool_name,
90
+ when=[predicate],
91
+ remember_minutes=rule.remember_minutes,
92
+ )
93
+ for predicate in rule.when
94
+ )
95
+ else:
96
+ new_rules.append(rule)
97
+ save_policy(data_dir, policy.model_copy(update={"rules": new_rules}))
98
+ logger.info(
99
+ "Migrated %s to ANY-match condition semantics: split %d multi-condition "
100
+ "rule(s) into one rule per condition (%d -> %d rules) and stamped "
101
+ "schema_version. Conditions that previously ALL had to match now EACH "
102
+ "gate on their own.",
103
+ path,
104
+ split,
105
+ len(policy.rules),
106
+ len(new_rules),
107
+ )
108
+ return True
109
+
110
+
111
+ def save_policy(data_dir: Path, policy: Policy) -> None:
112
+ # Bump version on every save so optimistic-concurrency callers can
113
+ # detect mid-flight edits (PUT /api/policy/config 409s when the
114
+ # caller's payload version != on-disk version).
115
+ bumped = policy.model_copy(update={"version": policy.version + 1})
116
+ data_dir.mkdir(parents=True, exist_ok=True)
117
+ path = data_dir / POLICY_FILENAME
118
+ fd, tmp_path = tempfile.mkstemp(prefix=f".{POLICY_FILENAME}.", dir=data_dir)
119
+ try:
120
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
121
+ json.dump(bumped.model_dump(mode="json"), f, indent=2, sort_keys=True)
122
+ os.replace(tmp_path, path)
123
+ except Exception:
124
+ Path(tmp_path).unlink(missing_ok=True)
125
+ raise
@@ -249,7 +249,23 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
249
249
  # what it offers to Home Assistant conversation agents on it.
250
250
  from .llm_exposure import LlmExposureMiddleware
251
251
 
252
- self.mcp.add_middleware(LlmExposureMiddleware())
252
+ # policy_live: whether the gating middleware/queue actually wired at
253
+ # startup — stamped so a client can distinguish "configured" from
254
+ # "enforcing" on the very connection it is using (#1990).
255
+ self.mcp.add_middleware(
256
+ LlmExposureMiddleware(
257
+ policy_live=lambda: getattr(self, "approval_queue", None) is not None
258
+ )
259
+ )
260
+
261
+ # Entity visibility enforce mode, INBOUND half (#2015) — always
262
+ # installed, consults the live config per request (no-op unless
263
+ # enforce is on with an active hide dimension). Must precede the
264
+ # read-only guard and PolicyMiddleware: a call naming a hidden
265
+ # entity is concealed as not-found BEFORE it can be stored verbatim
266
+ # in the approval queue (rendered in the settings UI) or answered
267
+ # with a read-only/approval response that would confirm existence.
268
+ self._apply_visibility_inbound_middleware()
253
269
 
254
270
  # Read Only Mode write blocker (discussion #1569) — always
255
271
  # installed, consults the live flag per call. Before
@@ -269,6 +285,11 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
269
285
  # wraps the final tool surface (including the search proxies).
270
286
  self._apply_tool_security_policies()
271
287
 
288
+ # Entity visibility enforce mode, OUTBOUND half (#2015) — added LAST
289
+ # so it is innermost: its result scan sees the raw tool output before
290
+ # any other middleware transforms it.
291
+ self._apply_visibility_outbound_middleware()
292
+
272
293
  def _get_skills_dir(self) -> Path | None:
273
294
  """Return the bundled skills directory if it exists.
274
295
 
@@ -1008,6 +1029,25 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
1008
1029
  UI take effect immediately without restart and without a stale
1009
1030
  in-memory cache.
1010
1031
  """
1032
+ # One-time ANY-match schema migration (PR #1993) runs even when
1033
+ # policies are disabled, so the file already matches the editor's
1034
+ # ANY semantics whenever the user turns the feature on. Never
1035
+ # blocks startup.
1036
+ try:
1037
+ from .policy.persistence import migrate_policy_any_semantics
1038
+ from .utils.data_paths import get_data_dir as _get_data_dir
1039
+
1040
+ migrate_policy_any_semantics(_get_data_dir())
1041
+ except Exception:
1042
+ logger.error(
1043
+ "tool_policy.json ANY-match migration failed; continuing. The "
1044
+ "file may still carry pre-ANY semantics: multi-condition rules "
1045
+ "will gate only when ALL conditions match, while the policy "
1046
+ "editor presents them as ANY-match. Fix the file (or re-save "
1047
+ "the policy in the settings UI) and restart.",
1048
+ exc_info=True,
1049
+ )
1050
+
1011
1051
  if not self.settings.enable_tool_security_policies:
1012
1052
  return
1013
1053
 
@@ -1125,6 +1165,35 @@ class HomeAssistantSmartMCPServer(EnhancedToolsMixin):
1125
1165
 
1126
1166
  self.mcp.add_middleware(StrictBpsMiddleware(list_tools=_list_all_tools))
1127
1167
 
1168
+ def _apply_visibility_inbound_middleware(self) -> None:
1169
+ """Install the enforce-mode INBOUND (conceal/refuse) middleware (#2015).
1170
+
1171
+ Always installed — self-no-ops at call time (loads the visibility
1172
+ config per request and passes through unless enforce is on with an
1173
+ active hide dimension), so a settings-UI toggle applies live in
1174
+ standalone-HTTP/embedded mode like ``read_only_mode``. It needs the HA
1175
+ client to fetch the registry/states for the hidden-set computation;
1176
+ inject the lazy client accessor so no eager connection is made at
1177
+ startup. Ordering (before read-only/policy) is owned by the caller.
1178
+ """
1179
+ from .visibility.enforcement import VisibilityInboundEnforcement
1180
+
1181
+ self.mcp.add_middleware(
1182
+ VisibilityInboundEnforcement(get_client=lambda: self.client)
1183
+ )
1184
+
1185
+ def _apply_visibility_outbound_middleware(self) -> None:
1186
+ """Install the enforce-mode OUTBOUND (result-scan) middleware (#2015).
1187
+
1188
+ Same always-installed/self-no-op contract as the inbound half; the
1189
+ caller registers it last so it is innermost and scans raw tool output.
1190
+ """
1191
+ from .visibility.enforcement import VisibilityOutboundEnforcement
1192
+
1193
+ self.mcp.add_middleware(
1194
+ VisibilityOutboundEnforcement(get_client=lambda: self.client)
1195
+ )
1196
+
1128
1197
  # Shared action-phrased keyword block for retrieval. Some MCP clients
1129
1198
  # (Claude Code, others) rank candidate tools by token-overlap between
1130
1199
  # the user's natural-language query and each tool's `description`
@@ -244,32 +244,42 @@ async def _delete_backups_bulk(
244
244
  )
245
245
 
246
246
 
247
- async def _get_backup_config(
248
- server: HomeAssistantSmartMCPServer | None, _: Request
249
- ) -> JSONResponse:
250
- """Return live auto-backup config + per-field origin + editable flag.
251
-
252
- Per-field origin/editable matrix (see ``config.get_backup_setting_origin``):
253
- - ``addon``: editable POST routes through Supervisor.
254
- - ``env``: read-only — env var wins; user must unset to edit.
255
- - ``file``/``default``: editable — POST writes the override file.
247
+ def backup_config_fields() -> list[dict[str, Any]]:
248
+ """Auto-backup fields with per-field value/origin/editable.
249
+
250
+ Shared by the HTTP ``_get_backup_config`` handler and the
251
+ ``ha_dev_manage_settings`` developer tool so the two never drift.
252
+ Origin/editable matrix (see ``config.get_backup_setting_origin``):
253
+ ``addon`` editable (Supervisor), ``env`` read-only, ``file``/``default``
254
+ editable (override file).
256
255
  """
257
256
  settings = get_global_settings()
258
- addon_mode = is_running_in_addon()
259
- fields = []
257
+ fields: list[dict[str, Any]] = []
260
258
  for field_name, env_name, _ftype in BACKUP_OVERRIDE_FIELDS:
261
259
  origin = get_backup_setting_origin(env_name)
262
- editable = origin in ("addon", "file", "default")
263
260
  fields.append(
264
261
  {
265
262
  "field": field_name,
266
263
  "env_var": env_name,
267
264
  "value": getattr(settings, field_name),
268
265
  "origin": origin,
269
- "editable": editable,
266
+ "editable": origin in ("addon", "file", "default"),
270
267
  }
271
268
  )
272
- return JSONResponse({"success": True, "is_addon": addon_mode, "fields": fields})
269
+ return fields
270
+
271
+
272
+ async def _get_backup_config(
273
+ server: HomeAssistantSmartMCPServer | None, _: Request
274
+ ) -> JSONResponse:
275
+ """Return live auto-backup config + per-field origin + editable flag."""
276
+ return JSONResponse(
277
+ {
278
+ "success": True,
279
+ "is_addon": is_running_in_addon(),
280
+ "fields": backup_config_fields(),
281
+ }
282
+ )
273
283
 
274
284
 
275
285
  # Inclusive bounds for the integer auto-backup fields; a field absent here
@@ -414,36 +424,15 @@ async def _save_backup_config_addon(
414
424
  )
415
425
 
416
426
 
417
- async def _save_backup_config(
418
- server: HomeAssistantSmartMCPServer | None, request: Request
419
- ) -> JSONResponse:
420
- """Persist auto-backup config edits and publish to the live process.
427
+ def _apply_backup_config_file(clean: dict[str, Any]) -> JSONResponse:
428
+ """Standalone/file-mode branch of ``apply_backup_config``.
421
429
 
422
- Routing:
423
- - Addon mode: POST ``/addons/self/options`` and return
424
- ``restart_required=True`` (see ``_save_backup_config_addon``).
425
- - Standalone (file) mode: refuse any field that's pinned by an env var
426
- (process or ``.env``) — return 409 with the offending names so the UI
427
- can refresh and show the read-only banner. Editable fields merge into
428
- ``<data_dir>/backup_settings.json`` and a Settings cache reset
429
- publishes them immediately, hence ``restart_required=False``.
430
+ Refuse any field pinned by an env var (process or ``.env``) — 409 with
431
+ the offending names so the UI can refresh and show the read-only banner.
432
+ Editable fields merge into ``<data_dir>/backup_settings.json`` and a
433
+ Settings cache reset publishes them immediately, hence
434
+ ``restart_required=False``.
430
435
  """
431
- try:
432
- payload = await request.json()
433
- except (ValueError, json.JSONDecodeError):
434
- return _bad_request("Invalid JSON body")
435
- clean, err = _validate_backup_payload(payload)
436
- if err is not None:
437
- return _bad_request(err)
438
-
439
- if is_running_in_addon():
440
- # ``is_running_in_addon()`` checks SUPERVISOR_TOKEN — the
441
- # ``_save_backup_config_addon`` helper called below also catches the
442
- # missing-token ``RuntimeError`` from ``make_supervisor_httpx_client``
443
- # as defense-in-depth.
444
- return await _save_backup_config_addon(server, clean)
445
-
446
- # Standalone (file) mode — refuse to override env-pinned fields.
447
436
  rejected = _filter_env_pinned_backup_fields(clean)
448
437
  if rejected:
449
438
  return JSONResponse(
@@ -483,6 +472,51 @@ async def _save_backup_config(
483
472
  )
484
473
 
485
474
 
475
+ async def apply_backup_config(
476
+ server: HomeAssistantSmartMCPServer | None, clean: dict[str, Any]
477
+ ) -> JSONResponse:
478
+ """Route already-validated auto-backup fields to their persistence path.
479
+
480
+ Addon mode POSTs to Supervisor (``_save_backup_config_addon``);
481
+ standalone/file mode merges the override file (``_apply_backup_config_file``).
482
+ Shared core of the HTTP save handler (``_save_backup_config``) and the
483
+ ``ha_dev_manage_settings`` developer tool so both take identical
484
+ addon-vs-file routing and env-pin rejection — ``clean`` must already be
485
+ validated by ``_validate_backup_payload``.
486
+ """
487
+ if is_running_in_addon():
488
+ # ``is_running_in_addon()`` checks SUPERVISOR_TOKEN — the
489
+ # ``_save_backup_config_addon`` helper also catches the missing-token
490
+ # ``RuntimeError`` from ``make_supervisor_httpx_client`` as
491
+ # defense-in-depth.
492
+ return await _save_backup_config_addon(server, clean)
493
+ # Same write-guard treatment as tool_config/tool_policy: the RMW is
494
+ # synchronous (safe in-process), but the guard's file lock keeps a
495
+ # writer in another process from interleaving with it.
496
+ from ..utils.config_write_lock import config_write_guard
497
+
498
+ async with config_write_guard():
499
+ return _apply_backup_config_file(clean)
500
+
501
+
502
+ async def _save_backup_config(
503
+ server: HomeAssistantSmartMCPServer | None, request: Request
504
+ ) -> JSONResponse:
505
+ """Persist auto-backup config edits and publish to the live process.
506
+
507
+ Parses and validates the request body, then delegates the addon-vs-file
508
+ routing to ``apply_backup_config`` (shared with the developer tool).
509
+ """
510
+ try:
511
+ payload = await request.json()
512
+ except (ValueError, json.JSONDecodeError):
513
+ return _bad_request("Invalid JSON body")
514
+ clean, err = _validate_backup_payload(payload)
515
+ if err is not None:
516
+ return _bad_request(err)
517
+ return await apply_backup_config(server, clean)
518
+
519
+
486
520
  def build_backups_handlers(
487
521
  server: HomeAssistantSmartMCPServer | None,
488
522
  ) -> dict[str, Any]: