Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
1265c31
feat(policy): scaffold policy package for per-tool approval (#966)
kingpanther13 May 23, 2026
c1409b9
feat(policy): add Predicate/Rule/Policy data models (#966)
kingpanther13 May 23, 2026
870cae3
feat(addon): expose enable_per_tool_approval option (#966)
kingpanther13 May 23, 2026
8a6bdc6
feat(config): add enable_per_tool_approval setting (#966)
kingpanther13 May 23, 2026
3451a30
feat(policy): atomic load/save for tool_policy.json (#966)
kingpanther13 May 23, 2026
9f7f3e1
feat(addon): wire enable_per_tool_approval through start.py + docs (#…
kingpanther13 May 23, 2026
f89336d
feat(policy): args-hash + remember-cache for approval queue (#966)
kingpanther13 May 23, 2026
3d89dc3
feat(policy): predicate evaluator (eq/in/regex/exists/...) (#966)
kingpanther13 May 23, 2026
f8c4550
feat(policy): pending entries with TTL, decisions, and event signalli…
kingpanther13 May 23, 2026
fa97a75
feat(policy): PolicyMiddleware happy-path branches (#966)
kingpanther13 May 23, 2026
5557d36
test(policy): cover block/deny/timeout/recall/remember branches (#966)
kingpanther13 May 23, 2026
10657a6
feat(policy): /api/policy/* Starlette handlers (#966)
kingpanther13 May 23, 2026
be2e1e2
fix(policy): wrap ValidationError, scope contains op, regex doc, test…
kingpanther13 May 23, 2026
a15e141
feat(policy): Policies tab in web UI + sidecar route wiring (#966)
kingpanther13 May 23, 2026
08cf8b6
feat(policy): register PolicyMiddleware on the FastMCP server (#966)
kingpanther13 May 23, 2026
d30cbb6
fix(policy): return 400 on malformed approve/deny bodies (#966)
kingpanther13 May 23, 2026
0741dcd
feat(toolsearch): unpin yaml-edit and code-mode tools, gated by appro…
kingpanther13 May 23, 2026
5ea9581
chore: ruff format + lint cleanup for policy package (#966)
kingpanther13 May 23, 2026
877056e
fix(policy): mypy narrowing for evaluator comparisons (#966)
kingpanther13 May 23, 2026
6ba94c3
docs: credit @L1AD and PolicyLayer for #966 inspiration
kingpanther13 May 23, 2026
7113760
docs(addon): fix wrong YAML example in enable_per_tool_approval secti…
kingpanther13 May 23, 2026
1bbc73e
fix(policy): CI green + critical bugs from reviewer cycle (#966)
kingpanther13 May 23, 2026
60b34ed
refactor(policy): drop default_action + tighten Rule.tool_name (#966)
kingpanther13 May 23, 2026
cc342b7
refactor(policy): encapsulate decision state + clean naming (#966)
kingpanther13 May 23, 2026
b23f73a
fix(toolsearch): default-pinned tools should be user-unpinnable (#966)
kingpanther13 May 23, 2026
992f0e2
refactor(policy): rename feature to "Tool Security Policies" (#966)
kingpanther13 May 23, 2026
99fc3e1
docs: small comment polish for policy review nits (#966)
kingpanther13 May 23, 2026
8867ef5
feat(ui): per-tool security-gated toggle in Tools tab (#966)
kingpanther13 May 23, 2026
7b5a41a
test(policy): integration + timing-isolation coverage gaps (#966)
kingpanther13 May 23, 2026
7252b11
feat(ui): rewrite Tool Security Policies tab — per-tool cards + predi…
kingpanther13 May 23, 2026
d4c19ec
Merge remote-tracking branch 'upstream/master' into issue-966-per-too…
kingpanther13 May 23, 2026
fd3bc5c
feat(config): expose enable_tool_security_policies as a feature flag …
kingpanther13 May 23, 2026
4dfdfb4
fix(policy): address all verified review findings + CI failures (#966)
kingpanther13 May 23, 2026
d018821
fix(policy): CI green + real e2e test for the approval flow (#966)
kingpanther13 May 23, 2026
d093f67
fix(ui): broken quote escaping in predicate-form placeholder breaks J…
kingpanther13 May 23, 2026
8a062ba
fix(ui): gated toggle reads addon-config flag, not Policy.enabled (#966)
kingpanther13 May 24, 2026
a3cf454
fix(policy): Policy.extra=ignore so old persisted files load cleanly …
kingpanther13 May 24, 2026
2edb043
fix(policy): drop Policy.enabled — addon-config flag is the sole swit…
kingpanther13 May 24, 2026
b3e8107
fix(policy): drop approve_url, instruct LLM to send user to settings …
kingpanther13 May 24, 2026
c183cc7
feat(policy): schema-driven condition builder for write/destructive t…
kingpanther13 May 24, 2026
45c9d2c
test: include new policy handler keys in sidecar all-keys assertion (…
kingpanther13 May 24, 2026
1fb26d6
fix(ui): clearer condition-builder labels, optional value, bareword i…
kingpanther13 May 24, 2026
a8673e0
feat(policy): wildcard path "args.*" + clearer empty-value semantics …
kingpanther13 May 24, 2026
6999e1d
fix(ui): default condition path to '(any argument)'; relabel error (#…
kingpanther13 May 24, 2026
2ba4dcf
feat(policy): auto-save conditions + surface matched_rule in approval…
kingpanther13 May 24, 2026
44a0bad
feat(policy): case-insensitive string comparison in all ops (#966)
kingpanther13 May 24, 2026
95cbb28
fix(policy): mypy bool cast + broaden e2e coverage (#966)
kingpanther13 May 24, 2026
e6fcf62
refactor(policy): address review-cycle findings (#966)
kingpanther13 May 24, 2026
a91ba0f
fix(policy): UI surface fetch failures + middleware reissues swept pe…
kingpanther13 May 24, 2026
a6439b8
fix(policy): logger.info on silent decide-False; debug log on gt/lt t…
kingpanther13 May 24, 2026
f7e86ee
style: ruff format evaluator.py for CI's 0.15.13 (#966)
kingpanther13 May 24, 2026
d27f583
feat(policy): clear remember-cache on save, clearer disabled-state UX…
kingpanther13 May 24, 2026
94c3299
Merge upstream/master into issue-966-per-tool-approval
kingpanther13 May 24, 2026
9456d30
test(policy): fix JS-harness drift guard + lock policy-tab behaviour …
kingpanther13 May 24, 2026
108d6e4
refactor(policy): address 2nd-round review findings (#966)
kingpanther13 May 24, 2026
88704cf
fix(ui): blank value on eq/in/etc coerces to op=exists (#966)
kingpanther13 May 24, 2026
45871f2
docs(addon): drop beta tag from Tool Security Policies (#966)
kingpanther13 May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- **[FastMCP](https://github.com/jlowin/fastmcp)**: Excellent MCP server framework
- **[Model Context Protocol](https://modelcontextprotocol.io/)**: Standardized AI-application communication
- **[Claude Code](https://github.com/anthropics/claude-code)**: AI-powered coding assistant
- **[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).

## 👥 Contributors

Expand Down Expand Up @@ -360,6 +361,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- **[@drseanwing](https://github.com/drseanwing)** — Progress emission via FastMCP `Context` in long-running tools (#1124); tool-discovery / categorized-search docs (#1123).
- **[@fnordpig](https://github.com/fnordpig)** — Config subentry support (#1393) and Assist pipeline management tool (#1392).
- **[@paul43210](https://github.com/paul43210)** — `array_patch` mode in `ha_manage_addon` for atomic GET-modify-POST (#1063).
- **[@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.

---

Expand Down
1 change: 1 addition & 0 deletions homeassistant-addon-dev/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The dev add-on uses the same configuration as the stable version. See the main a
| `backup_hint` | Backup strength preference | `normal` |
| `secret_path` | Custom secret path (optional) | auto-generated |
| `enable_tool_search` | Replace full tool catalog with search-based discovery (~46K → ~5K tokens). ⚠️ Do NOT enable for Claude Sonnet/Opus — their built-in tool search conflicts with ha-mcp's. Disable one or the other. | `false` |
| `enable_tool_security_policies` | Gate high-stakes tool calls (lock/alarm control, automation writes, etc.) behind user approval. Guarded calls block until the user clicks Approve in the Tool Security Policies tab of the web UI. Per-tool rules with optional argument conditions are configured in that same tab. | `false` |
| `enable_yaml_config_editing` *(beta)* | Enables `ha_config_set_yaml` for editing `configuration.yaml` directly. Requires `ha_mcp_tools` custom component. | `false` |
| `enable_filesystem_tools` *(beta)* | Enables file read/write tools (`ha_list_files`, `ha_read_file`, `ha_write_file`, `ha_delete_file`). Requires `ha_mcp_tools` custom component. | `false` |
| `enable_custom_component_integration` *(beta)* | Enables `ha_install_mcp_tools` installer tool for the `ha_mcp_tools` custom component. | `false` |
Expand Down
2 changes: 2 additions & 0 deletions homeassistant-addon-dev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ image: "ghcr.io/homeassistant-ai/ha-mcp-addon-dev-{arch}"
options:
backup_hint: "normal"
enable_tool_search: false
enable_tool_security_policies: false
enable_yaml_config_editing: false
enable_code_mode: false
enable_lite_docstrings: false
Expand All @@ -38,6 +39,7 @@ schema:
backup_hint: list(strong|normal|weak|auto)
secret_path: str?
enable_tool_search: bool?
enable_tool_security_policies: bool?
enable_yaml_config_editing: bool?
enable_code_mode: bool?
enable_lite_docstrings: bool?
Expand Down
9 changes: 9 additions & 0 deletions homeassistant-addon-dev/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ configuration:
OpenAI-compatible models) or with smaller context windows. Tools
are found via ha_search_tools and executed via categorized proxies
(read/write/delete). Requires restart to take effect.
enable_tool_security_policies:
name: Enable Tool Security Policies (advanced)
description: >-
Gate high-stakes tool calls (lock/alarm control, automation writes,
etc.) behind user approval. When a guarded tool is called, the agent
tells the user to open the Tool Security Policies tab in the web UI
and click Approve before the call proceeds. Per-tool rules with
optional argument conditions are configured in the Tool Security
Policies tab. Off by default. Requires restart to take effect.
enable_yaml_config_editing:
name: Enable YAML config editing (beta)
description: >-
Expand Down
24 changes: 22 additions & 2 deletions homeassistant-addon/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,13 +272,33 @@ Replaces the full tool catalog (~88 tools, ~46K tokens) with search-based discov

Requires add-on restart to take effect.

### enable_tool_security_policies

**Default:** `false`

Gates high-stakes tool calls (lock/alarm control, automation writes, etc.) behind explicit user approval. When a guarded tool is called, the agent is told to ask the user to open the Tool Security Policies tab in the web UI, and the call is held until the user clicks **Approve** there. Per-tool rules — with optional argument conditions — are configured from the same Tool Security Policies tab.

**When to enable:**
- Shared installations where you want a human in the loop for destructive or security-relevant operations
- Locks, alarms, and other entities where an LLM mistake has real-world consequences
- Whenever you want a per-call user-approval prompt before high-stakes operations run (locks, automations, etc.)

**When to leave disabled (default):**
- Single-user setups where you're comfortable with the LLM acting autonomously
- You haven't configured any policy rules yet (with no rules, the toggle has no effect — but the runtime cost is small either way)

Off by default. Requires add-on restart to take effect.

**Example Configuration:**

```yaml
backup_hint: normal
secret_path: "" # Leave empty for auto-generation
enable_tool_security_policies: true
```

Per-tool rules (including argument conditions like `args.domain in ['lock', 'alarm_control_panel']`) are configured from the **Tool Security Policies** tab in the web UI, not from `config.yaml`.

*Inspired by [PolicyLayer](https://policylayer.com/)'s policy DSL shape, originally proposed in [#966](https://github.com/homeassistant-ai/ha-mcp/issues/966) by [@L1AD](https://github.com/L1AD).*

---

## Security
Expand Down
2 changes: 2 additions & 0 deletions homeassistant-addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ image: "ghcr.io/homeassistant-ai/ha-mcp-addon-{arch}"
options:
backup_hint: "normal"
enable_tool_search: false
enable_tool_security_policies: false
enable_auto_backup: true
auto_backup_throttle_minutes: 0
auto_backup_retain_per_entity: 100
Expand All @@ -40,6 +41,7 @@ schema:
backup_hint: list(strong|normal|weak|auto)
secret_path: str?
enable_tool_search: bool?
enable_tool_security_policies: bool?
enable_auto_backup: bool?
auto_backup_throttle_minutes: int(0,1440)?
auto_backup_retain_per_entity: int(1,10000)?
Expand Down
12 changes: 12 additions & 0 deletions homeassistant-addon/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ def main() -> int:
backup_hint = "normal" # default
custom_secret_path = "" # default
enable_tool_search = False # default
enable_tool_security_policies = False # default
enable_yaml_config_editing = False # default
enable_filesystem_tools = False # default
enable_custom_component_integration = False # default
Expand All @@ -245,6 +246,14 @@ def main() -> int:
enable_tool_search = (
raw_tool_search if isinstance(raw_tool_search, bool) else False
)
raw_tool_security_policies = config.get(
"enable_tool_security_policies", False
)
enable_tool_security_policies = (
raw_tool_security_policies
if isinstance(raw_tool_security_policies, bool)
else False
)
raw_yaml_config = config.get("enable_yaml_config_editing", False)
enable_yaml_config_editing = (
raw_yaml_config if isinstance(raw_yaml_config, bool) else False
Expand Down Expand Up @@ -322,6 +331,9 @@ def main() -> int:
os.environ["HOMEASSISTANT_URL"] = "http://supervisor/core"
os.environ["BACKUP_HINT"] = backup_hint
os.environ["ENABLE_TOOL_SEARCH"] = str(enable_tool_search).lower()
os.environ["ENABLE_TOOL_SECURITY_POLICIES"] = str(
enable_tool_security_policies
).lower()
os.environ["ENABLE_YAML_CONFIG_EDITING"] = str(enable_yaml_config_editing).lower()
os.environ["HAMCP_ENABLE_FILESYSTEM_TOOLS"] = str(enable_filesystem_tools).lower()
os.environ["HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION"] = str(
Expand Down
9 changes: 9 additions & 0 deletions homeassistant-addon/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ configuration:
OpenAI-compatible models) or with smaller context windows. Tools
are found via ha_search_tools and executed via categorized proxies
(read/write/delete). Requires restart to take effect.
enable_tool_security_policies:
name: Enable Tool Security Policies (advanced)
description: >-
Gate high-stakes tool calls (lock/alarm control, automation writes,
etc.) behind user approval. When a guarded tool is called, the agent
tells the user to open the Tool Security Policies tab in the web UI
and click Approve before the call proceeds. Per-tool rules with
optional argument conditions are configured in the Tool Security
Policies tab. Off by default. Requires restart to take effect.
enable_auto_backup:
name: Enable auto-backup of edits
description: >-
Expand Down
8 changes: 8 additions & 0 deletions src/ha_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ class Settings(BaseSettings):
# Dramatically reduces idle context token usage for LLMs.
enable_tool_search: bool = Field(False, alias="ENABLE_TOOL_SEARCH")

# Tool security policies middleware — opt-in gate that routes high-stakes
# tool calls through a per-tool policy with out-of-band web-UI approval
# (issue #966). Disabled by default.
enable_tool_security_policies: bool = Field(
False, alias="ENABLE_TOOL_SECURITY_POLICIES"
)

# Managed YAML config editing — allows ha_config_set_yaml to add,
# replace, or remove top-level keys in configuration.yaml and package
# files. Disabled by default; only for YAML-only features with no UI/API path.
Expand Down Expand Up @@ -330,6 +337,7 @@ def validate_settings() -> tuple[bool, str | None]:
"HAMCP_ENABLE_CUSTOM_COMPONENT_INTEGRATION",
bool,
),
("enable_tool_security_policies", "ENABLE_TOOL_SECURITY_POLICIES", bool),
)

# Override-file location is the same data dir that holds tool_config.json
Expand Down
21 changes: 18 additions & 3 deletions src/ha_mcp/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ class ErrorCode(StrEnum):
SANDBOX_SYNTAX_UNSUPPORTED = "SANDBOX_SYNTAX_UNSUPPORTED"
SANDBOX_RUNTIME_ERROR = "SANDBOX_RUNTIME_ERROR"

# Tool security policy gating (#966). The middleware gates a tool
# call awaiting user approval, the user denied it, or the policy
# file itself failed to load (treated as a fail-closed safety stop).
USER_APPROVAL_REQUIRED = "USER_APPROVAL_REQUIRED"
USER_DENIED = "USER_DENIED"
POLICY_LOAD_FAILED = "POLICY_LOAD_FAILED"


# Default suggestions for common error codes
DEFAULT_SUGGESTIONS: dict[ErrorCode, list[str]] = {
Expand Down Expand Up @@ -240,7 +247,9 @@ def create_error_response(
}
"""
# Use provided suggestions or fall back to defaults
error_suggestions = suggestions if suggestions else DEFAULT_SUGGESTIONS.get(code, [])
error_suggestions = (
suggestions if suggestions else DEFAULT_SUGGESTIONS.get(code, [])
)

error_dict: dict[str, Any] = {
"code": code.value,
Expand Down Expand Up @@ -331,14 +340,20 @@ def create_validation_error(
context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a validation error response."""
code = ErrorCode.VALIDATION_INVALID_JSON if invalid_json else ErrorCode.VALIDATION_FAILED
code = (
ErrorCode.VALIDATION_INVALID_JSON
if invalid_json
else ErrorCode.VALIDATION_FAILED
)
# Build context, prioritizing explicit context but adding parameter if provided
final_context: dict[str, Any] = {}
if context:
final_context.update(context)
if parameter:
final_context["parameter"] = parameter
return create_error_response(code, message, details, context=final_context if final_context else None)
return create_error_response(
code, message, details, context=final_context if final_context else None
)


def create_config_error(
Expand Down
1 change: 1 addition & 0 deletions src/ha_mcp/policy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tool security policies for high-stakes MCP tool calls."""
Loading
Loading