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.
Files changed (124) hide show
  1. {ha_mcp_dev-7.5.0.dev589/src/ha_mcp_dev.egg-info → ha_mcp_dev-7.5.0.dev591}/PKG-INFO +3 -1
  2. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/README.md +2 -0
  3. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/pyproject.toml +1 -1
  4. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/__main__.py +35 -11
  5. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/config.py +8 -0
  6. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/errors.py +18 -3
  7. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/__init__.py +1 -0
  8. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/approval_queue.py +264 -0
  9. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/evaluator.py +144 -0
  10. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/handlers.py +263 -0
  11. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/middleware.py +225 -0
  12. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/model.py +122 -0
  13. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/persistence.py +43 -0
  14. ha_mcp_dev-7.5.0.dev591/src/ha_mcp/policy/value_sources.py +179 -0
  15. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/server.py +106 -12
  16. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/settings_ui.py +1305 -39
  17. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/stdio_settings_sidecar.py +39 -0
  18. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/transforms/categorized_search.py +22 -4
  19. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591/src/ha_mcp_dev.egg-info}/PKG-INFO +3 -1
  20. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/SOURCES.txt +8 -0
  21. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/LICENSE +0 -0
  22. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/MANIFEST.in +0 -0
  23. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/setup.cfg +0 -0
  24. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/__init__.py +0 -0
  25. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/_pypi_marker +0 -0
  26. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/_version.py +0 -0
  27. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/auth/__init__.py +0 -0
  28. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/auth/consent_form.py +0 -0
  29. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/auth/provider.py +0 -0
  30. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/backup_manager.py +0 -0
  31. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/__init__.py +0 -0
  32. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/rest_client.py +0 -0
  33. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/supervisor_client.py +0 -0
  34. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/websocket_client.py +0 -0
  35. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/client/websocket_listener.py +0 -0
  36. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/py.typed +0 -0
  37. {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
  38. {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
  39. {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
  40. {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
  41. {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
  42. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/AGENTS.md +0 -0
  43. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/CLAUDE.md +0 -0
  44. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/CONTRIBUTING.md +0 -0
  45. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/LICENSE +0 -0
  46. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/resources/skills-vendor/README.md +0 -0
  47. {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
  48. {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
  49. {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
  50. {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
  51. {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
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/smoke_test.py +0 -0
  60. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/__init__.py +0 -0
  61. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/auto_backup.py +0 -0
  62. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/backup.py +0 -0
  63. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/best_practice_checker.py +0 -0
  64. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/device_control.py +0 -0
  65. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/enhanced.py +0 -0
  66. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/helpers.py +0 -0
  67. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/reference_validator.py +0 -0
  68. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/registry.py +0 -0
  69. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/smart_search.py +0 -0
  70. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_addons.py +0 -0
  71. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_areas.py +0 -0
  72. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_blueprints.py +0 -0
  73. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_bug_report.py +0 -0
  74. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_calendar.py +0 -0
  75. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_camera.py +0 -0
  76. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_categories.py +0 -0
  77. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_code.py +0 -0
  78. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_automations.py +0 -0
  79. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_dashboards.py +0 -0
  80. {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
  81. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_helpers.py +0 -0
  82. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_scenes.py +0 -0
  83. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_config_scripts.py +0 -0
  84. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_energy.py +0 -0
  85. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_entities.py +0 -0
  86. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_filesystem.py +0 -0
  87. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_groups.py +0 -0
  88. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_hacs.py +0 -0
  89. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_history.py +0 -0
  90. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_integrations.py +0 -0
  91. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_labels.py +0 -0
  92. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_mcp_component.py +0 -0
  93. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_registry.py +0 -0
  94. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_resources.py +0 -0
  95. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_search.py +0 -0
  96. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_service.py +0 -0
  97. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_services.py +0 -0
  98. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_system.py +0 -0
  99. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_todo.py +0 -0
  100. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_traces.py +0 -0
  101. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_updates.py +0 -0
  102. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_utility.py +0 -0
  103. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_voice_assistant.py +0 -0
  104. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_yaml_config.py +0 -0
  105. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/tools_zones.py +0 -0
  106. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/tools/util_helpers.py +0 -0
  107. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/transforms/__init__.py +0 -0
  108. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/transforms/lite_docstrings.py +0 -0
  109. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/__init__.py +0 -0
  110. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/config_hash.py +0 -0
  111. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/data_paths.py +0 -0
  112. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/domain_handlers.py +0 -0
  113. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/fuzzy_search.py +0 -0
  114. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/kill_signal_diagnostics.py +0 -0
  115. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/operation_manager.py +0 -0
  116. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/python_sandbox.py +0 -0
  117. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp/utils/usage_logger.py +0 -0
  118. {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
  119. {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
  120. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/src/ha_mcp_dev.egg-info/requires.txt +0 -0
  121. {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
  122. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/tests/__init__.py +0 -0
  123. {ha_mcp_dev-7.5.0.dev589 → ha_mcp_dev-7.5.0.dev591}/tests/test_constants.py +0 -0
  124. {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.dev589
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.dev589"
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": "0.0.0.0",
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(_run_oauth_server(ha_url, base_url, port, path), "OAuth server")
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(ha_url: str, base_url: str, port: int, path: str) -> None:
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(mcp.run_async(**_http_run_kwargs("http", port, path)))
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 = suggestions if suggestions else DEFAULT_SUGGESTIONS.get(code, [])
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 = ErrorCode.VALIDATION_INVALID_JSON if invalid_json else ErrorCode.VALIDATION_FAILED
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(code, message, details, context=final_context if final_context else None)
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