ha-mcp-dev 7.5.0.dev589__tar.gz → 7.5.0.dev590__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.dev590}/PKG-INFO +3 -1
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/README.md +2 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/pyproject.toml +1 -1
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/config.py +8 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/errors.py +18 -3
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/__init__.py +1 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/approval_queue.py +264 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/evaluator.py +144 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/handlers.py +263 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/middleware.py +225 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/model.py +122 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/persistence.py +43 -0
- ha_mcp_dev-7.5.0.dev590/src/ha_mcp/policy/value_sources.py +179 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/server.py +106 -12
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/settings_ui.py +1305 -39
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/stdio_settings_sidecar.py +39 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/transforms/categorized_search.py +22 -4
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590/src/ha_mcp_dev.egg-info}/PKG-INFO +3 -1
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp_dev.egg-info/SOURCES.txt +8 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/MANIFEST.in +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/setup.cfg +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/__main__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/_pypi_marker +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/_version.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/auth/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/auth/consent_form.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/auth/provider.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/backup_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/client/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/client/rest_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/client/supervisor_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/client/websocket_client.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/client/websocket_listener.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/py.typed +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/resources/skills-vendor/.claude/settings.json +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/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.dev590}/src/ha_mcp/smoke_test.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/auto_backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/backup.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/best_practice_checker.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/device_control.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/enhanced.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/reference_validator.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/smart_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_addons.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_areas.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_blueprints.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_bug_report.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_calendar.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_camera.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_categories.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_code.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_config_automations.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_config_entry_flow.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_energy.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_entities.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_filesystem.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_groups.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_hacs.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_history.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_integrations.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_labels.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_registry.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_resources.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_service.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_services.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_system.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_todo.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_traces.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_updates.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_utility.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/tools_zones.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/tools/util_helpers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/transforms/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/config_hash.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/data_paths.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/domain_handlers.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/fuzzy_search.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/operation_manager.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/python_sandbox.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp/utils/usage_logger.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp_dev.egg-info/dependency_links.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp_dev.egg-info/entry_points.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/src/ha_mcp_dev.egg-info/top_level.txt +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/tests/__init__.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/tests/test_constants.py +0 -0
- {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev590}/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.dev590
|
|
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.dev590"
|
|
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"
|
|
@@ -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
|