ha-mcp-dev 7.5.0.dev589__tar.gz → 7.5.0.dev591__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.5.0.dev589/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev591}/PKG-INFO +3 -1
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/README.md +2 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/pyproject.toml +1 -1
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/__main__.py +35 -11
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/config.py +8 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/errors.py +18 -3
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/__init__.py +1 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/approval_queue.py +264 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/evaluator.py +144 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/handlers.py +263 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/middleware.py +225 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/model.py +122 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/persistence.py +43 -0
- ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/value_sources.py +179 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/server.py +106 -12
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/settings_ui.py +1305 -39
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/stdio_settings_sidecar.py +39 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/transforms/categorized_search.py +22 -4
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591/src/ha_mcp_dev.egg-info}/PKG-INFO +3 -1
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/SOURCES.txt +8 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/setup.cfg +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/.claude-plugin/marketplace.json +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/.claude-plugin/plugin.json +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/.github/ISSUE_TEMPLATE/skill-rca.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/.github/pull_request_template.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/SKILL.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/evals/evals.json +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/automation-patterns.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-cards.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/dashboard-guide.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/device-control.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/domain-docs.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/examples.yaml +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/helper-selection.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/safe-refactoring.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/template-guidelines.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/skills/home-assistant-best-practices/references/yaml-only-integrations.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/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.5.0.
|
|
3
|
+
Version: 7.5.0.dev591
|
|
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
|
|
@@ -342,6 +342,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
342
342
|
- **[FastMCP](https://github.com/jlowin/fastmcp)**: Excellent MCP server framework
|
|
343
343
|
- **[Model Context Protocol](https://modelcontextprotocol.io/)**: Standardized AI-application communication
|
|
344
344
|
- **[Claude Code](https://github.com/anthropics/claude-code)**: AI-powered coding assistant
|
|
345
|
+
- **[PolicyLayer](https://policylayer.com/)**: Argument-path predicate DSL shape (`args.domain in [...]` with `eq`/`in`/`regex`/`contains`/`exists`/...) inspired the per-tool approval rule schema (#966).
|
|
345
346
|
|
|
346
347
|
## 👥 Contributors
|
|
347
348
|
|
|
@@ -390,6 +391,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
390
391
|
- **[@drseanwing](https://github.com/drseanwing)** — Progress emission via FastMCP `Context` in long-running tools (#1124); tool-discovery / categorized-search docs (#1123).
|
|
391
392
|
- **[@fnordpig](https://github.com/fnordpig)** — Config subentry support (#1393) and Assist pipeline management tool (#1392).
|
|
392
393
|
- **[@paul43210](https://github.com/paul43210)** — `array_patch` mode in `ha_manage_addon` for atomic GET-modify-POST (#1063).
|
|
394
|
+
- **[@L1AD](https://github.com/L1AD)** — Filed #966 proposing tool security policies; pointed to PolicyLayer's MCP-security work as prior art that inspired the predicate DSL shape.
|
|
393
395
|
|
|
394
396
|
---
|
|
395
397
|
|
|
@@ -312,6 +312,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
312
312
|
- **[FastMCP](https://github.com/jlowin/fastmcp)**: Excellent MCP server framework
|
|
313
313
|
- **[Model Context Protocol](https://modelcontextprotocol.io/)**: Standardized AI-application communication
|
|
314
314
|
- **[Claude Code](https://github.com/anthropics/claude-code)**: AI-powered coding assistant
|
|
315
|
+
- **[PolicyLayer](https://policylayer.com/)**: Argument-path predicate DSL shape (`args.domain in [...]` with `eq`/`in`/`regex`/`contains`/`exists`/...) inspired the per-tool approval rule schema (#966).
|
|
315
316
|
|
|
316
317
|
## 👥 Contributors
|
|
317
318
|
|
|
@@ -360,6 +361,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
|
360
361
|
- **[@drseanwing](https://github.com/drseanwing)** — Progress emission via FastMCP `Context` in long-running tools (#1124); tool-discovery / categorized-search docs (#1123).
|
|
361
362
|
- **[@fnordpig](https://github.com/fnordpig)** — Config subentry support (#1393) and Assist pipeline management tool (#1392).
|
|
362
363
|
- **[@paul43210](https://github.com/paul43210)** — `array_patch` mode in `ha_manage_addon` for atomic GET-modify-POST (#1063).
|
|
364
|
+
- **[@L1AD](https://github.com/L1AD)** — Filed #966 proposing tool security policies; pointed to PolicyLayer's MCP-security work as prior art that inspired the predicate DSL shape.
|
|
363
365
|
|
|
364
366
|
---
|
|
365
367
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ha-mcp-dev"
|
|
7
|
-
version = "7.5.0.
|
|
7
|
+
version = "7.5.0.dev591"
|
|
8
8
|
description = "Home Assistant MCP Server - Complete control of Home Assistant through MCP"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13,<3.14"
|
|
@@ -288,11 +288,11 @@ def _setup_standard_mode() -> None:
|
|
|
288
288
|
_log_startup_version()
|
|
289
289
|
|
|
290
290
|
|
|
291
|
-
def _http_run_kwargs(transport: str, port: int, path: str) -> dict:
|
|
291
|
+
def _http_run_kwargs(transport: str, host: str, port: int, path: str) -> dict:
|
|
292
292
|
"""Build common run_async kwargs for HTTP-based transports."""
|
|
293
293
|
return {
|
|
294
294
|
"transport": transport,
|
|
295
|
-
"host":
|
|
295
|
+
"host": host,
|
|
296
296
|
"port": port,
|
|
297
297
|
"path": path,
|
|
298
298
|
"show_banner": _get_show_banner(),
|
|
@@ -724,13 +724,26 @@ def main_dev() -> None:
|
|
|
724
724
|
|
|
725
725
|
|
|
726
726
|
# HTTP entry point for web clients
|
|
727
|
-
def _get_http_runtime(default_port: int = 8086) -> tuple[int, str]:
|
|
727
|
+
def _get_http_runtime(default_port: int = 8086) -> tuple[str, int, str]:
|
|
728
728
|
"""Return runtime configuration shared by HTTP transports.
|
|
729
729
|
|
|
730
730
|
Args:
|
|
731
731
|
default_port: Default port to use if MCP_PORT env var is not set.
|
|
732
|
+
|
|
733
|
+
The bind host comes from ``MCP_HOST`` and defaults to ``0.0.0.0``. The
|
|
734
|
+
explicit literal default is load-bearing: FastMCP's own ``Settings.host``
|
|
735
|
+
defaults to ``127.0.0.1``, so dropping the fallback would silently flip
|
|
736
|
+
the default and break existing LAN deployments. Set ``MCP_HOST=127.0.0.1``
|
|
737
|
+
to bind to loopback on workstation deployments.
|
|
738
|
+
|
|
739
|
+
Note: FastMCP also honors a ``FASTMCP_HOST`` env var natively, but
|
|
740
|
+
because ``_http_run_kwargs`` passes ``host=`` explicitly to
|
|
741
|
+
``run_async``, any ``FASTMCP_HOST`` value in the environment is
|
|
742
|
+
ignored — ``MCP_HOST`` is the only env var that affects bind host
|
|
743
|
+
for ha-mcp's CLI entry points.
|
|
732
744
|
"""
|
|
733
745
|
|
|
746
|
+
host = os.getenv("MCP_HOST", "0.0.0.0")
|
|
734
747
|
port_str = os.getenv("MCP_PORT", str(default_port))
|
|
735
748
|
try:
|
|
736
749
|
port = int(port_str)
|
|
@@ -738,17 +751,18 @@ def _get_http_runtime(default_port: int = 8086) -> tuple[int, str]:
|
|
|
738
751
|
logger.error(f"Invalid MCP_PORT value: {port_str!r}. Must be an integer.")
|
|
739
752
|
sys.exit(1)
|
|
740
753
|
path = os.getenv("MCP_SECRET_PATH", "/mcp")
|
|
741
|
-
return port, path
|
|
754
|
+
return host, port, path
|
|
742
755
|
|
|
743
756
|
|
|
744
757
|
async def _run_http_with_graceful_shutdown(
|
|
745
758
|
transport: str,
|
|
759
|
+
host: str,
|
|
746
760
|
port: int,
|
|
747
761
|
path: str,
|
|
748
762
|
) -> None:
|
|
749
763
|
"""Run HTTP server with graceful shutdown support."""
|
|
750
764
|
await _run_with_shutdown(
|
|
751
|
-
_get_mcp().run_async(**_http_run_kwargs(transport, port, path))
|
|
765
|
+
_get_mcp().run_async(**_http_run_kwargs(transport, host, port, path))
|
|
752
766
|
)
|
|
753
767
|
|
|
754
768
|
|
|
@@ -820,12 +834,12 @@ def _run_http_server(transport: str, default_port: int = 8086) -> None:
|
|
|
820
834
|
"""
|
|
821
835
|
from ha_mcp.settings_ui import register_settings_routes
|
|
822
836
|
|
|
823
|
-
port, path = _get_http_runtime(default_port)
|
|
837
|
+
host, port, path = _get_http_runtime(default_port)
|
|
824
838
|
register_browser_landing(_get_mcp(), path)
|
|
825
839
|
register_settings_routes(_get_mcp(), _get_server(), secret_path=path)
|
|
826
840
|
|
|
827
841
|
_run_entrypoint(
|
|
828
|
-
_run_http_with_graceful_shutdown(transport, port, path),
|
|
842
|
+
_run_http_with_graceful_shutdown(transport, host, port, path),
|
|
829
843
|
"HTTP server",
|
|
830
844
|
)
|
|
831
845
|
|
|
@@ -836,6 +850,7 @@ def main_web() -> None:
|
|
|
836
850
|
Environment:
|
|
837
851
|
- HOMEASSISTANT_URL (required)
|
|
838
852
|
- HOMEASSISTANT_TOKEN (required)
|
|
853
|
+
- MCP_HOST (optional, default: "0.0.0.0"; set 127.0.0.1 to restrict to loopback)
|
|
839
854
|
- MCP_PORT (optional, default: 8086)
|
|
840
855
|
- MCP_SECRET_PATH (optional, default: "/mcp")
|
|
841
856
|
"""
|
|
@@ -849,6 +864,7 @@ def main_sse() -> None:
|
|
|
849
864
|
Environment:
|
|
850
865
|
- HOMEASSISTANT_URL (required)
|
|
851
866
|
- HOMEASSISTANT_TOKEN (required)
|
|
867
|
+
- MCP_HOST (optional, default: "0.0.0.0"; set 127.0.0.1 to restrict to loopback)
|
|
852
868
|
- MCP_PORT (optional, default: 8087)
|
|
853
869
|
- MCP_SECRET_PATH (optional, default: "/mcp")
|
|
854
870
|
"""
|
|
@@ -866,6 +882,7 @@ def main_oauth() -> None:
|
|
|
866
882
|
Environment:
|
|
867
883
|
- HOMEASSISTANT_URL (required): URL of the Home Assistant instance
|
|
868
884
|
- MCP_BASE_URL (required): Public URL where this server is accessible (e.g., https://your-tunnel.com)
|
|
885
|
+
- MCP_HOST (optional, default: "0.0.0.0"; set 127.0.0.1 to restrict to loopback)
|
|
869
886
|
- MCP_PORT (optional, default: 8086)
|
|
870
887
|
- MCP_SECRET_PATH (optional, default: "/mcp")
|
|
871
888
|
- LOG_LEVEL (optional, default: INFO)
|
|
@@ -891,7 +908,7 @@ def main_oauth() -> None:
|
|
|
891
908
|
logger.info(f"OAuth mode logging configured at {log_level} level")
|
|
892
909
|
_log_startup_version()
|
|
893
910
|
|
|
894
|
-
port, path = _get_http_runtime(default_port=8086)
|
|
911
|
+
host, port, path = _get_http_runtime(default_port=8086)
|
|
895
912
|
base_url = os.getenv("MCP_BASE_URL")
|
|
896
913
|
ha_url = os.getenv("HOMEASSISTANT_URL")
|
|
897
914
|
|
|
@@ -924,15 +941,20 @@ For setup instructions, see:
|
|
|
924
941
|
# Type narrowing: ha_url and base_url are guaranteed non-None after the check above
|
|
925
942
|
assert ha_url is not None
|
|
926
943
|
assert base_url is not None
|
|
927
|
-
_run_entrypoint(
|
|
944
|
+
_run_entrypoint(
|
|
945
|
+
_run_oauth_server(ha_url, base_url, host, port, path), "OAuth server"
|
|
946
|
+
)
|
|
928
947
|
|
|
929
948
|
|
|
930
|
-
async def _run_oauth_server(
|
|
949
|
+
async def _run_oauth_server(
|
|
950
|
+
ha_url: str, base_url: str, host: str, port: int, path: str
|
|
951
|
+
) -> None:
|
|
931
952
|
"""Run the OAuth-authenticated MCP server.
|
|
932
953
|
|
|
933
954
|
Args:
|
|
934
955
|
ha_url: Home Assistant instance URL (server-side config)
|
|
935
956
|
base_url: Public URL where this server is accessible (required)
|
|
957
|
+
host: Bind host (typically 0.0.0.0; override via MCP_HOST)
|
|
936
958
|
port: Port to listen on
|
|
937
959
|
path: MCP endpoint path
|
|
938
960
|
"""
|
|
@@ -968,7 +990,9 @@ async def _run_oauth_server(ha_url: str, base_url: str, port: int, path: str) ->
|
|
|
968
990
|
f"Starting OAuth-enabled MCP server with {len(tools)} tools on {base_url}{path}"
|
|
969
991
|
)
|
|
970
992
|
|
|
971
|
-
await _run_with_shutdown(
|
|
993
|
+
await _run_with_shutdown(
|
|
994
|
+
mcp.run_async(**_http_run_kwargs("http", host, port, path))
|
|
995
|
+
)
|
|
972
996
|
|
|
973
997
|
|
|
974
998
|
if __name__ == "__main__":
|
|
@@ -100,6 +100,13 @@ class Settings(BaseSettings):
|
|
|
100
100
|
# Dramatically reduces idle context token usage for LLMs.
|
|
101
101
|
enable_tool_search: bool = Field(False, alias="ENABLE_TOOL_SEARCH")
|
|
102
102
|
|
|
103
|
+
# Tool security policies middleware — opt-in gate that routes high-stakes
|
|
104
|
+
# tool calls through a per-tool policy with out-of-band web-UI approval
|
|
105
|
+
# (issue #966). Disabled by default.
|
|
106
|
+
enable_tool_security_policies: bool = Field(
|
|
107
|
+
False, alias="ENABLE_TOOL_SECURITY_POLICIES"
|
|
108
|
+
)
|
|
109
|
+
|
|
103
110
|
# Managed YAML config editing — allows ha_config_set_yaml to add,
|
|
104
111
|
# replace, or remove top-level keys in configuration.yaml and package
|
|
105
112
|
# files. Disabled by default; only for YAML-only features with no UI/API path.
|
|
@@ -330,6 +337,7 @@ FEATURE_FLAG_FIELDS: tuple[tuple[str, str, type], ...] = (
|
|
|
330
337
|
"HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION",
|
|
331
338
|
bool,
|
|
332
339
|
),
|
|
340
|
+
("enable_tool_security_policies", "ENABLE_TOOL_SECURITY_POLICIES", bool),
|
|
333
341
|
)
|
|
334
342
|
|
|
335
343
|
# Override-file location is the same data dir that holds tool_config.json
|
|
@@ -89,6 +89,13 @@ class ErrorCode(StrEnum):
|
|
|
89
89
|
SANDBOX_SYNTAX_UNSUPPORTED = "SANDBOX_SYNTAX_UNSUPPORTED"
|
|
90
90
|
SANDBOX_RUNTIME_ERROR = "SANDBOX_RUNTIME_ERROR"
|
|
91
91
|
|
|
92
|
+
# Tool security policy gating (#966). The middleware gates a tool
|
|
93
|
+
# call awaiting user approval, the user denied it, or the policy
|
|
94
|
+
# file itself failed to load (treated as a fail-closed safety stop).
|
|
95
|
+
USER_APPROVAL_REQUIRED = "USER_APPROVAL_REQUIRED"
|
|
96
|
+
USER_DENIED = "USER_DENIED"
|
|
97
|
+
POLICY_LOAD_FAILED = "POLICY_LOAD_FAILED"
|
|
98
|
+
|
|
92
99
|
|
|
93
100
|
# Default suggestions for common error codes
|
|
94
101
|
DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
|
|
@@ -240,7 +247,9 @@ def create_error_response(
|
|
|
240
247
|
}
|
|
241
248
|
"""
|
|
242
249
|
# Use provided suggestions or fall back to defaults
|
|
243
|
-
error_suggestions =
|
|
250
|
+
error_suggestions = (
|
|
251
|
+
suggestions if suggestions else DEFAULT_SUGGESTIONS.get(code, [])
|
|
252
|
+
)
|
|
244
253
|
|
|
245
254
|
error_dict: dict[str, Any] = {
|
|
246
255
|
"code": code.value,
|
|
@@ -331,14 +340,20 @@ def create_validation_error(
|
|
|
331
340
|
context: dict[str, Any] | None = None,
|
|
332
341
|
) -> dict[str, Any]:
|
|
333
342
|
"""Create a validation error response."""
|
|
334
|
-
code =
|
|
343
|
+
code = (
|
|
344
|
+
ErrorCode.VALIDATION_INVALID_JSON
|
|
345
|
+
if invalid_json
|
|
346
|
+
else ErrorCode.VALIDATION_FAILED
|
|
347
|
+
)
|
|
335
348
|
# Build context, prioritizing explicit context but adding parameter if provided
|
|
336
349
|
final_context: dict[str, Any] = {}
|
|
337
350
|
if context:
|
|
338
351
|
final_context.update(context)
|
|
339
352
|
if parameter:
|
|
340
353
|
final_context["parameter"] = parameter
|
|
341
|
-
return create_error_response(
|
|
354
|
+
return create_error_response(
|
|
355
|
+
code, message, details, context=final_context if final_context else None
|
|
356
|
+
)
|
|
342
357
|
|
|
343
358
|
|
|
344
359
|
def create_config_error(
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tool security policies for high-stakes MCP tool calls."""
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""In-memory, per-process approval queue with args-hash binding and remember-cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime, timedelta
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import anyio
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
Decision = Literal["pending", "approved", "denied"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_args_hash(args: dict[str, Any]) -> str:
|
|
21
|
+
"""Canonical sha256 of args. Same hash function used at insert and lookup."""
|
|
22
|
+
payload = json.dumps(args, sort_keys=True, separators=(",", ":"), default=str)
|
|
23
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PendingApproval:
|
|
28
|
+
token: str
|
|
29
|
+
tool_name: str
|
|
30
|
+
args_hash: str
|
|
31
|
+
args: dict[str, Any]
|
|
32
|
+
created_at: datetime
|
|
33
|
+
expires_at: datetime
|
|
34
|
+
_decision: Decision = "pending"
|
|
35
|
+
_event: anyio.Event = field(default_factory=anyio.Event)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def decision(self) -> Decision:
|
|
39
|
+
return self._decision
|
|
40
|
+
|
|
41
|
+
def decide(self, outcome: Literal["approved", "denied"]) -> bool:
|
|
42
|
+
"""Transition pending -> outcome exactly once. Returns False if already decided."""
|
|
43
|
+
if self._decision != "pending":
|
|
44
|
+
return False
|
|
45
|
+
self._decision = outcome
|
|
46
|
+
self._event.set()
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
async def wait(self) -> Decision:
|
|
50
|
+
"""Block until decided; return the final Decision."""
|
|
51
|
+
await self._event.wait()
|
|
52
|
+
return self._decision
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if self.expires_at <= self.created_at:
|
|
56
|
+
raise ValueError("expires_at must be after created_at")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ApprovalQueue:
|
|
60
|
+
"""In-memory per-process approval queue with args-hash binding.
|
|
61
|
+
|
|
62
|
+
Pending approvals are bound to (tool_name, sha256(canonical_args)).
|
|
63
|
+
A re-call with mutated args produces a different hash and a new
|
|
64
|
+
pending entry, so an approval cannot be silently repurposed.
|
|
65
|
+
|
|
66
|
+
**Single-process only.** Multi-worker deployments (e.g.
|
|
67
|
+
``uvicorn --workers N``) are unsupported — approvals created on
|
|
68
|
+
worker A do NOT propagate to worker B, so a re-call routed to a
|
|
69
|
+
different worker will look like a brand-new approval request.
|
|
70
|
+
The standard ha-mcp deployments (stdio, addon, ha-mcp-web) all
|
|
71
|
+
run single-worker.
|
|
72
|
+
|
|
73
|
+
**Restart loses pending tokens.** The persistent ``tool_policy.json``
|
|
74
|
+
rules survive a restart, but any in-flight approval tokens do not.
|
|
75
|
+
Users will need to re-issue an approval click after a restart.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
# Hard cap on pending entries to prevent memory exhaustion if an LLM
|
|
79
|
+
# in a retry loop with mutated args creates a new entry every call.
|
|
80
|
+
# When the cap is hit, eviction runs in this order:
|
|
81
|
+
# 1. _sweep_expired() — drop TTL-elapsed entries first
|
|
82
|
+
# 2. then evict already-resolved (approved/denied) entries by age
|
|
83
|
+
# 3. only if still over the cap, evict oldest pending entries —
|
|
84
|
+
# and fire their _event so any waiter wakes up immediately
|
|
85
|
+
# rather than blocking the full wait_seconds against a row
|
|
86
|
+
# that no longer exists.
|
|
87
|
+
# 1000 is well above any realistic interactive use; an attacker
|
|
88
|
+
# probing past the cap just churns the queue.
|
|
89
|
+
PENDING_CAP = 1000
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self._by_token: dict[str, PendingApproval] = {}
|
|
93
|
+
self._remember: dict[tuple[str, str], datetime] = {}
|
|
94
|
+
# Serialises find_or_create against concurrent on_call_tool
|
|
95
|
+
# invocations with identical (tool_name, args_hash) — without it
|
|
96
|
+
# two coroutines could both find() == None then both create()
|
|
97
|
+
# separate pending entries, and approving one would leave the
|
|
98
|
+
# other waiter blocked forever.
|
|
99
|
+
self._create_lock = anyio.Lock()
|
|
100
|
+
|
|
101
|
+
# --- remember cache ---
|
|
102
|
+
def remember(self, tool_name: str, args_hash: str, *, minutes: int) -> None:
|
|
103
|
+
if minutes <= 0:
|
|
104
|
+
return
|
|
105
|
+
self._remember[(tool_name, args_hash)] = datetime.now(UTC) + timedelta(
|
|
106
|
+
minutes=minutes
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def is_remembered(self, tool_name: str, args_hash: str) -> bool:
|
|
110
|
+
until = self._remember.get((tool_name, args_hash))
|
|
111
|
+
if until is None:
|
|
112
|
+
return False
|
|
113
|
+
if datetime.now(UTC) >= until:
|
|
114
|
+
self._remember.pop((tool_name, args_hash), None)
|
|
115
|
+
return False
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def clear_remember_cache(self) -> None:
|
|
119
|
+
"""Drop every remembered approval. Called when the policy is
|
|
120
|
+
saved so a tightened rule takes effect immediately instead of
|
|
121
|
+
being silently bypassed by an in-flight remembered approval
|
|
122
|
+
until its window expires."""
|
|
123
|
+
self._remember.clear()
|
|
124
|
+
|
|
125
|
+
# --- pending entries lifecycle ---
|
|
126
|
+
def create(
|
|
127
|
+
self,
|
|
128
|
+
tool_name: str,
|
|
129
|
+
args_hash: str,
|
|
130
|
+
args: dict[str, Any],
|
|
131
|
+
*,
|
|
132
|
+
ttl_minutes: int,
|
|
133
|
+
) -> PendingApproval:
|
|
134
|
+
# Enforce PENDING_CAP. Order matters — see class docstring.
|
|
135
|
+
if len(self._by_token) >= self.PENDING_CAP:
|
|
136
|
+
self._sweep_expired()
|
|
137
|
+
if len(self._by_token) >= self.PENDING_CAP:
|
|
138
|
+
self._evict_to_make_room()
|
|
139
|
+
now = datetime.now(UTC)
|
|
140
|
+
entry = PendingApproval(
|
|
141
|
+
token=secrets.token_urlsafe(24),
|
|
142
|
+
tool_name=tool_name,
|
|
143
|
+
args_hash=args_hash,
|
|
144
|
+
args=args,
|
|
145
|
+
created_at=now,
|
|
146
|
+
expires_at=now + timedelta(minutes=ttl_minutes),
|
|
147
|
+
)
|
|
148
|
+
self._by_token[entry.token] = entry
|
|
149
|
+
return entry
|
|
150
|
+
|
|
151
|
+
async def find_or_create(
|
|
152
|
+
self,
|
|
153
|
+
tool_name: str,
|
|
154
|
+
args_hash: str,
|
|
155
|
+
args: dict[str, Any],
|
|
156
|
+
*,
|
|
157
|
+
ttl_minutes: int,
|
|
158
|
+
) -> PendingApproval:
|
|
159
|
+
"""Atomic find-then-create: prevents two concurrent on_call_tool
|
|
160
|
+
coroutines with identical (tool_name, args_hash) from creating
|
|
161
|
+
two separate pending entries that would then each block their
|
|
162
|
+
own waiter independently."""
|
|
163
|
+
async with self._create_lock:
|
|
164
|
+
existing = self.find(tool_name, args_hash)
|
|
165
|
+
if existing is not None:
|
|
166
|
+
return existing
|
|
167
|
+
return self.create(tool_name, args_hash, args, ttl_minutes=ttl_minutes)
|
|
168
|
+
|
|
169
|
+
def find(self, tool_name: str, args_hash: str) -> PendingApproval | None:
|
|
170
|
+
self._sweep_expired()
|
|
171
|
+
for entry in self._by_token.values():
|
|
172
|
+
if entry.tool_name == tool_name and entry.args_hash == args_hash:
|
|
173
|
+
return entry
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def get(self, token: str) -> PendingApproval | None:
|
|
177
|
+
self._sweep_expired()
|
|
178
|
+
return self._by_token.get(token)
|
|
179
|
+
|
|
180
|
+
def list_pending(self) -> list[PendingApproval]:
|
|
181
|
+
self._sweep_expired()
|
|
182
|
+
return [e for e in self._by_token.values() if e.decision == "pending"]
|
|
183
|
+
|
|
184
|
+
def approve(self, token: str) -> bool:
|
|
185
|
+
"""Mark the entry approved. Returns False if unknown or already decided."""
|
|
186
|
+
entry = self._by_token.get(token)
|
|
187
|
+
if entry is None:
|
|
188
|
+
# WARNING because on a security-gating endpoint this means
|
|
189
|
+
# either a UI bug, a stale tab racing the sweeper, or an
|
|
190
|
+
# attacker probing tokens — operator should see it.
|
|
191
|
+
logger.warning("approval_queue.approve: unknown token %s", token)
|
|
192
|
+
return False
|
|
193
|
+
ok = entry.decide("approved")
|
|
194
|
+
if not ok:
|
|
195
|
+
# INFO — could be a legitimate race (two approvers, or
|
|
196
|
+
# quick double-click) rather than a security signal.
|
|
197
|
+
logger.info(
|
|
198
|
+
"approval_queue.approve: token %s already decided as %s",
|
|
199
|
+
token,
|
|
200
|
+
entry.decision,
|
|
201
|
+
)
|
|
202
|
+
return ok
|
|
203
|
+
|
|
204
|
+
def deny(self, token: str) -> bool:
|
|
205
|
+
"""Mark the entry denied. Returns False if unknown or already decided."""
|
|
206
|
+
entry = self._by_token.get(token)
|
|
207
|
+
if entry is None:
|
|
208
|
+
logger.warning("approval_queue.deny: unknown token %s", token)
|
|
209
|
+
return False
|
|
210
|
+
ok = entry.decide("denied")
|
|
211
|
+
if not ok:
|
|
212
|
+
logger.info(
|
|
213
|
+
"approval_queue.deny: token %s already decided as %s",
|
|
214
|
+
token,
|
|
215
|
+
entry.decision,
|
|
216
|
+
)
|
|
217
|
+
return ok
|
|
218
|
+
|
|
219
|
+
def remove(self, token: str) -> None:
|
|
220
|
+
self._by_token.pop(token, None)
|
|
221
|
+
|
|
222
|
+
def consume_and_maybe_remember(
|
|
223
|
+
self, entry: PendingApproval, *, remember_minutes: int
|
|
224
|
+
) -> None:
|
|
225
|
+
self.remove(entry.token)
|
|
226
|
+
if remember_minutes > 0:
|
|
227
|
+
self.remember(entry.tool_name, entry.args_hash, minutes=remember_minutes)
|
|
228
|
+
|
|
229
|
+
def _sweep_expired(self) -> None:
|
|
230
|
+
now = datetime.now(UTC)
|
|
231
|
+
stale = [t for t, e in self._by_token.items() if e.expires_at <= now]
|
|
232
|
+
for t in stale:
|
|
233
|
+
self._by_token.pop(t, None)
|
|
234
|
+
|
|
235
|
+
def _evict_to_make_room(self) -> None:
|
|
236
|
+
"""Evict one entry to bring us back under PENDING_CAP.
|
|
237
|
+
|
|
238
|
+
Resolved entries (approved/denied) go first — they're already
|
|
239
|
+
decided and only sitting in the queue because the UI hasn't
|
|
240
|
+
picked them up yet. If none exist, fall back to the oldest
|
|
241
|
+
pending entry AND fire its event so any waiter in
|
|
242
|
+
_wait_for_decision wakes up immediately and observes the
|
|
243
|
+
eviction instead of blocking the full wait_seconds against a
|
|
244
|
+
row that no longer exists.
|
|
245
|
+
"""
|
|
246
|
+
overflow = len(self._by_token) - self.PENDING_CAP + 1
|
|
247
|
+
# Sort by (still-pending? then by age) so resolved entries are
|
|
248
|
+
# at the front of the evict list.
|
|
249
|
+
ordered = sorted(
|
|
250
|
+
self._by_token.values(),
|
|
251
|
+
key=lambda e: (e.decision == "pending", e.created_at),
|
|
252
|
+
)
|
|
253
|
+
for stale in ordered[:overflow]:
|
|
254
|
+
if stale.decision == "pending":
|
|
255
|
+
logger.warning(
|
|
256
|
+
"approval_queue: PENDING_CAP hit — evicting pending token %s "
|
|
257
|
+
"(no resolved entries to drop); waiter will be notified",
|
|
258
|
+
stale.token,
|
|
259
|
+
)
|
|
260
|
+
# Best-effort wake — _event.set() is idempotent and safe
|
|
261
|
+
# on any state. Without this the waiter blocks until its
|
|
262
|
+
# wait_seconds deadline.
|
|
263
|
+
stale._event.set()
|
|
264
|
+
self._by_token.pop(stale.token, None)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Evaluate a tool call against a Policy. Pure functions — no I/O, no state."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .model import Policy, Predicate, Rule
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Verdict(StrEnum):
|
|
15
|
+
ALLOW = "allow"
|
|
16
|
+
REQUIRE_APPROVAL = "require_approval"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iter_path_values(args: dict[str, Any], path: str) -> Iterator[Any]:
|
|
20
|
+
"""Yield every value the dotted path resolves to.
|
|
21
|
+
|
|
22
|
+
The leading ``args`` segment is implicit and stripped. A ``*`` segment
|
|
23
|
+
fans out across the current node — across dict values for dicts,
|
|
24
|
+
across items for lists — so ``args.*`` yields every top-level
|
|
25
|
+
argument, ``args.config.*`` yields every leaf of the ``config``
|
|
26
|
+
sub-dict, and so on. Empty iterator = no match.
|
|
27
|
+
"""
|
|
28
|
+
parts = path.split(".")
|
|
29
|
+
if parts[0] == "args":
|
|
30
|
+
parts = parts[1:]
|
|
31
|
+
|
|
32
|
+
def walk(cur: Any, rest: list[str]) -> Iterator[Any]:
|
|
33
|
+
if not rest:
|
|
34
|
+
yield cur
|
|
35
|
+
return
|
|
36
|
+
head, tail = rest[0], rest[1:]
|
|
37
|
+
if head == "*":
|
|
38
|
+
if isinstance(cur, dict):
|
|
39
|
+
for v in cur.values():
|
|
40
|
+
yield from walk(v, tail)
|
|
41
|
+
elif isinstance(cur, (list, tuple)):
|
|
42
|
+
for v in cur:
|
|
43
|
+
yield from walk(v, tail)
|
|
44
|
+
return
|
|
45
|
+
if isinstance(cur, dict) and head in cur:
|
|
46
|
+
yield from walk(cur[head], tail)
|
|
47
|
+
|
|
48
|
+
yield from walk(args, parts)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ci(x: Any) -> Any:
|
|
52
|
+
"""Lower-case strings for case-insensitive comparison; pass other
|
|
53
|
+
types through unchanged so type semantics (int != "1") survive.
|
|
54
|
+
Used on both sides of every string op — security gates should fire
|
|
55
|
+
whether the caller wrote 'Lock' or 'LOCK' or 'lock'."""
|
|
56
|
+
return x.lower() if isinstance(x, str) else x
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _op_matches(val: Any, op: str, pv: Any) -> bool:
|
|
60
|
+
"""Apply one op to one concrete value. Predicate dispatches over
|
|
61
|
+
the candidate values (which may be many for wildcard paths).
|
|
62
|
+
|
|
63
|
+
String comparisons are case-insensitive (security gates shouldn't
|
|
64
|
+
care whether the LLM lowercased its args). Non-string types
|
|
65
|
+
preserve their natural comparison semantics.
|
|
66
|
+
"""
|
|
67
|
+
match op:
|
|
68
|
+
case "eq":
|
|
69
|
+
return bool(_ci(val) == _ci(pv))
|
|
70
|
+
case "neq":
|
|
71
|
+
return bool(_ci(val) != _ci(pv))
|
|
72
|
+
case "in":
|
|
73
|
+
return _ci(val) in [_ci(x) for x in (pv or [])]
|
|
74
|
+
case "not_in":
|
|
75
|
+
return _ci(val) not in [_ci(x) for x in (pv or [])]
|
|
76
|
+
case "regex":
|
|
77
|
+
# `regex` is re.search (substring match). Anchor with ^...$
|
|
78
|
+
# for full-match. re.IGNORECASE so '^light\.' matches 'Light.x'.
|
|
79
|
+
return (
|
|
80
|
+
isinstance(val, str)
|
|
81
|
+
and isinstance(pv, str)
|
|
82
|
+
and re.search(pv, val, re.IGNORECASE) is not None
|
|
83
|
+
)
|
|
84
|
+
case "contains":
|
|
85
|
+
if isinstance(val, str) and isinstance(pv, str):
|
|
86
|
+
return pv.lower() in val.lower()
|
|
87
|
+
return isinstance(val, (list, tuple, set)) and pv in val
|
|
88
|
+
case "gt":
|
|
89
|
+
try:
|
|
90
|
+
return bool(val > pv)
|
|
91
|
+
except TypeError:
|
|
92
|
+
# Numeric rule against a non-numeric arg value — log so
|
|
93
|
+
# users can tell their "temperature > 30" rule isn't
|
|
94
|
+
# silently never firing because the arg is a string.
|
|
95
|
+
logger.debug(
|
|
96
|
+
"policy: gt type-mismatch (val=%r pv=%r) — predicate skipped",
|
|
97
|
+
val,
|
|
98
|
+
pv,
|
|
99
|
+
)
|
|
100
|
+
return False
|
|
101
|
+
case "lt":
|
|
102
|
+
try:
|
|
103
|
+
return bool(val < pv)
|
|
104
|
+
except TypeError:
|
|
105
|
+
logger.debug(
|
|
106
|
+
"policy: lt type-mismatch (val=%r pv=%r) — predicate skipped",
|
|
107
|
+
val,
|
|
108
|
+
pv,
|
|
109
|
+
)
|
|
110
|
+
return False
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def match_predicate(predicate: Predicate, args: dict[str, Any]) -> bool:
|
|
115
|
+
values = list(iter_path_values(args, predicate.path))
|
|
116
|
+
if predicate.op == "exists":
|
|
117
|
+
return bool(values)
|
|
118
|
+
if not values:
|
|
119
|
+
return False
|
|
120
|
+
# Existential semantics: a wildcard path matches if ANY value at the
|
|
121
|
+
# wildcard satisfies the op. For non-wildcard paths there's at most
|
|
122
|
+
# one value so the any() collapses to a single check.
|
|
123
|
+
return any(_op_matches(v, predicate.op, predicate.value) for v in values)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def match_rule(rule: Rule, tool_name: str, args: dict[str, Any]) -> bool:
|
|
127
|
+
if rule.tool_name != "*" and rule.tool_name != tool_name:
|
|
128
|
+
return False
|
|
129
|
+
return all(match_predicate(p, args) for p in rule.when)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def find_matching_rule(
|
|
133
|
+
tool_name: str, args: dict[str, Any], policy: Policy
|
|
134
|
+
) -> Rule | None:
|
|
135
|
+
for rule in policy.rules:
|
|
136
|
+
if match_rule(rule, tool_name, args):
|
|
137
|
+
return rule
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
|
|
142
|
+
if find_matching_rule(tool_name, args, policy) is not None:
|
|
143
|
+
return Verdict.REQUIRE_APPROVAL
|
|
144
|
+
return Verdict.ALLOW
|