Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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 per-tool approval gating; 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_per_tool_approval` *(beta)* | Gate high-stakes tool calls (lock/alarm control, automation writes, etc.) behind user approval. Guarded calls return an approval URL the user clicks to allow. Per-tool rules with optional argument predicates are configured in the Policies tab of the web UI. | `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_per_tool_approval: 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_per_tool_approval: 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_per_tool_approval:
name: Enable per-tool approval (advanced, beta)
description: >-
Gate high-stakes tool calls (lock/alarm control, automation writes,
etc.) behind user approval. When a guarded tool is called, the agent
receives an approval URL; the user must click Approve in the
Policies tab of the web UI before the call proceeds. Per-tool rules
with optional argument predicates are configured in the 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_per_tool_approval

**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 receives an approval URL and the call is held until the user clicks **Approve** in the **Policies** tab of the web UI. Per-tool rules — with optional argument predicates — are configured from the same 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 hard audit trail of which tool calls were explicitly allowed

**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)

The policy engine is in beta. Off by default. Requires add-on restart to take effect.

**Example Configuration:**

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

Per-tool rules (including argument predicates like `args.domain in ['lock', 'alarm_control_panel']`) are configured from the **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_per_tool_approval: 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_per_tool_approval: bool?
enable_auto_backup: bool?
auto_backup_throttle_minutes: int(0,1440)?
auto_backup_retain_per_entity: int(1,10000)?
Expand Down
8 changes: 8 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_per_tool_approval = False # default
enable_yaml_config_editing = False # default
enable_filesystem_tools = False # default
enable_custom_component_integration = False # default
Expand All @@ -245,6 +246,12 @@ def main() -> int:
enable_tool_search = (
raw_tool_search if isinstance(raw_tool_search, bool) else False
)
raw_per_tool_approval = config.get("enable_per_tool_approval", False)
enable_per_tool_approval = (
raw_per_tool_approval
if isinstance(raw_per_tool_approval, 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 +329,7 @@ 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_PER_TOOL_APPROVAL"] = str(enable_per_tool_approval).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_per_tool_approval:
name: Enable per-tool approval (advanced, beta)
description: >-
Gate high-stakes tool calls (lock/alarm control, automation writes,
etc.) behind user approval. When a guarded tool is called, the agent
receives an approval URL; the user must click Approve in the
Policies tab of the web UI before the call proceeds. Per-tool rules
with optional argument predicates are configured in the Policies
tab. Off by default. Requires restart to take effect.
enable_auto_backup:
name: Enable auto-backup of edits
description: >-
Expand Down
5 changes: 5 additions & 0 deletions src/ha_mcp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ class Settings(BaseSettings):
# Dramatically reduces idle context token usage for LLMs.
enable_tool_search: bool = Field(False, alias="ENABLE_TOOL_SEARCH")

# Per-tool approval 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_per_tool_approval: bool = Field(False, alias="ENABLE_PER_TOOL_APPROVAL")

# 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
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 @@
"""Per-tool approval policy for high-stakes MCP tool calls."""
122 changes: 122 additions & 0 deletions src/ha_mcp/policy/approval_queue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""In-memory, per-process approval queue with args-hash binding and remember-cache."""

from __future__ import annotations

import hashlib
import json
import secrets
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any, Literal

import anyio

Decision = Literal["pending", "approved", "denied"]


def compute_args_hash(args: dict[str, Any]) -> str:
"""Canonical sha256 of args. Same hash function used at insert and lookup."""
payload = json.dumps(args, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(payload.encode()).hexdigest()


@dataclass
class PendingApproval:
token: str
tool_name: str
args_hash: str
args_preview: dict[str, Any]
created_at: datetime
expires_at: datetime
decision: Decision = "pending"
event: anyio.Event = field(default_factory=anyio.Event)


class ApprovalQueue:
"""In-memory store. Per-process. Token-keyed for HTTP lookup,
(tool, hash)-indexed for re-call lookup."""

def __init__(self) -> None:
self._by_token: dict[str, PendingApproval] = {}
self._remember: dict[tuple[str, str], datetime] = {}

# --- remember cache ---
def remember(self, tool_name: str, args_hash: str, *, minutes: int) -> None:
if minutes <= 0:
return
self._remember[(tool_name, args_hash)] = datetime.now(UTC) + timedelta(
minutes=minutes
)

def is_remembered(self, tool_name: str, args_hash: str) -> bool:
until = self._remember.get((tool_name, args_hash))
if until is None:
return False
if datetime.now(UTC) >= until:
self._remember.pop((tool_name, args_hash), None)
return False
return True

# --- pending entries lifecycle ---
def create(
self,
tool_name: str,
args_hash: str,
args_preview: dict[str, Any],
*,
ttl_minutes: int,
) -> PendingApproval:
now = datetime.now(UTC)
entry = PendingApproval(
token=secrets.token_urlsafe(24),
tool_name=tool_name,
args_hash=args_hash,
args_preview=args_preview,
created_at=now,
expires_at=now + timedelta(minutes=ttl_minutes),
)
self._by_token[entry.token] = entry
return entry

def find(self, tool_name: str, args_hash: str) -> PendingApproval | None:
self._sweep_expired()
for entry in self._by_token.values():
if entry.tool_name == tool_name and entry.args_hash == args_hash:
return entry
return None

def get(self, token: str) -> PendingApproval | None:
self._sweep_expired()
return self._by_token.get(token)

def list_pending(self) -> list[PendingApproval]:
self._sweep_expired()
return [e for e in self._by_token.values() if e.decision == "pending"]

def approve(self, token: str) -> None:
entry = self._by_token.get(token)
if entry and entry.decision == "pending":
entry.decision = "approved"
entry.event.set()

def deny(self, token: str) -> None:
entry = self._by_token.get(token)
if entry and entry.decision == "pending":
entry.decision = "denied"
entry.event.set()

def remove(self, token: str) -> None:
self._by_token.pop(token, None)

def consume_and_maybe_remember(
self, entry: PendingApproval, *, remember_minutes: int
) -> None:
self.remove(entry.token)
if remember_minutes > 0:
self.remember(entry.tool_name, entry.args_hash, minutes=remember_minutes)

def _sweep_expired(self) -> None:
now = datetime.now(UTC)
stale = [t for t, e in self._by_token.items() if e.expires_at <= now]
for t in stale:
self._by_token.pop(t, None)
90 changes: 90 additions & 0 deletions src/ha_mcp/policy/evaluator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Evaluate a tool call against a Policy. Pure functions — no I/O, no state."""

import re
from enum import StrEnum
from typing import Any

from .model import Policy, Predicate, Rule

_MISSING = object()


class Verdict(StrEnum):
ALLOW = "allow"
REQUIRE_APPROVAL = "require_approval"


def extract_path(args: dict[str, Any], path: str) -> Any:
"""Walk a dotted path like 'args.config.alias' against the args dict.

'args' is implicit — the leading 'args.' is stripped. Returns _MISSING if any
intermediate key is absent.
"""
parts = path.split(".")
if parts[0] == "args":
parts = parts[1:]
cur: Any = args
for part in parts:
if not isinstance(cur, dict) or part not in cur:
return _MISSING
cur = cur[part]
return cur


def match_predicate(predicate: Predicate, args: dict[str, Any]) -> bool:
val = extract_path(args, predicate.path)
if predicate.op == "exists":
return val is not _MISSING
if val is _MISSING:
return False
pv = predicate.value
match predicate.op:
case "eq":
return bool(val == pv)
case "neq":
return bool(val != pv)
case "in":
return val in (pv or [])
case "not_in":
return val not in (pv or [])
# `regex` is re.search (substring match). Anchor with ^...$ for full-match.
case "regex":
return (
isinstance(val, str)
and isinstance(pv, str)
and re.search(pv, val) is not None
)
case "contains":
return isinstance(val, (str, list, tuple, set)) and pv in val
case "gt":
return bool(val > pv)
case "lt":
return bool(val < pv)
return False


def match_rule(rule: Rule, tool_name: str, args: dict[str, Any]) -> bool:
if rule.tool_name != "*" and rule.tool_name != tool_name:
return False
return all(match_predicate(p, args) for p in rule.when)


def find_matching_rule(
tool_name: str, args: dict[str, Any], policy: Policy
) -> Rule | None:
for rule in policy.rules:
if match_rule(rule, tool_name, args):
return rule
return None


def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
if not policy.enabled:
return Verdict.ALLOW
if find_matching_rule(tool_name, args, policy) is not None:
return Verdict.REQUIRE_APPROVAL
return (
Verdict.REQUIRE_APPROVAL
if policy.default_action == "require_approval"
else Verdict.ALLOW
)
Loading
Loading