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.
- {ha_mcp_dev-7.14.2.dev2107/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.14.2.dev2109}/PKG-INFO +1 -1
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/pyproject.toml +1 -1
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/errors.py +8 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/llm_exposure.py +67 -6
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/handlers.py +24 -18
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/middleware.py +24 -1
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/model.py +11 -0
- ha_mcp_dev-7.14.2.dev2109/src/ha_mcp/policy/persistence.py +125 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/server.py +70 -1
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_backups.py +76 -42
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_tools.py +59 -52
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_persistence.py +13 -6
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/de.json +7 -2
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/en.json +7 -2
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/ru.json +7 -2
- {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
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/settings.html +10 -3
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/settings.js +146 -48
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/registry.py +6 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_deep.py +26 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_dev.py +1023 -101
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_search.py +40 -1
- ha_mcp_dev-7.14.2.dev2109/src/ha_mcp/utils/config_write_lock.py +130 -0
- ha_mcp_dev-7.14.2.dev2109/src/ha_mcp/visibility/enforcement.py +590 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/model.py +7 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/resolver.py +94 -18
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109/src/ha_mcp_dev.egg-info}/PKG-INFO +1 -1
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/SOURCES.txt +2 -0
- ha_mcp_dev-7.14.2.dev2107/src/ha_mcp/policy/persistence.py +0 -43
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/LICENSE +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/README.md +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/setup.cfg +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/browser_landing.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/config.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/capture.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/content.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/paths.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/provision.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/dashboard_screenshot/theme_guard.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/approval_queue.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/evaluator.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/policy/value_sources.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/read_only.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_advanced.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_fs.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_server.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_theme.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_i18n.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_supervisor.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_theme.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_tools_meta.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/locales/README.md +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/settings.css +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/stdio_settings_sidecar.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/strict_bps.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_api.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_config_reads.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_devices.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_registries.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/component_registry_lookup.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/config_entry_flow.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/base.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/matter.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/thread.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/zigbee.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/radio/zwave.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_base.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_config.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_entities.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_fetch.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_overview.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_scenes.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/smart_search/_scoring.py +0 -0
- {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
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_dashboard_screenshot.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_radio.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_themes.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_yaml_read.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/tools/validation_middleware.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transforms/categorized_search.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/transport_security.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/update_check.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/skill_loader.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/visibility/persistence.py +0 -0
- {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
- {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
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {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
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/tests/test_env_manager.py +0 -0
|
@@ -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.
|
|
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}``
|
|
18
|
-
(one more loopback MCP client) filters on data that can never
|
|
19
|
-
server's settings, with zero extra round-trips.
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
{ha_mcp_dev-7.14.2.dev2107 → ha_mcp_dev-7.14.2.dev2109}/src/ha_mcp/settings_ui/_handlers_backups.py
RENAMED
|
@@ -244,32 +244,42 @@ async def _delete_backups_bulk(
|
|
|
244
244
|
)
|
|
245
245
|
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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":
|
|
266
|
+
"editable": origin in ("addon", "file", "default"),
|
|
270
267
|
}
|
|
271
268
|
)
|
|
272
|
-
return
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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]:
|