Skip to content

Commit 5ea9581

Browse files
committed
chore: ruff format + lint cleanup for policy package (homeassistant-ai#966)
- ruff format reflow on PR-touched files (case statements split to two lines, function signatures, line continuations). - UP042: Verdict now inherits from StrEnum instead of (str, Enum). - E402: hoist `import anyio` to the top of test_approval_queue.py. - I001: sort imports in test_evaluator.py and test_model.py. No behavior change.
1 parent 0741dcd commit 5ea9581

11 files changed

Lines changed: 239 additions & 134 deletions

File tree

src/ha_mcp/config.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,7 @@ class Settings(BaseSettings):
103103
# Per-tool approval middleware — opt-in gate that routes high-stakes tool
104104
# calls through a per-tool policy with out-of-band web-UI approval
105105
# (issue #966). Disabled by default.
106-
enable_per_tool_approval: bool = Field(
107-
False, alias="ENABLE_PER_TOOL_APPROVAL"
108-
)
106+
enable_per_tool_approval: bool = Field(False, alias="ENABLE_PER_TOOL_APPROVAL")
109107

110108
# Managed YAML config editing — allows ha_config_set_yaml to add,
111109
# replace, or remove top-level keys in configuration.yaml and package

src/ha_mcp/policy/approval_queue.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ def __init__(self) -> None:
4444
def remember(self, tool_name: str, args_hash: str, *, minutes: int) -> None:
4545
if minutes <= 0:
4646
return
47-
self._remember[(tool_name, args_hash)] = (
48-
datetime.now(UTC) + timedelta(minutes=minutes)
47+
self._remember[(tool_name, args_hash)] = datetime.now(UTC) + timedelta(
48+
minutes=minutes
4949
)
5050

5151
def is_remembered(self, tool_name: str, args_hash: str) -> bool:

src/ha_mcp/policy/evaluator.py

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
"""Evaluate a tool call against a Policy. Pure functions — no I/O, no state."""
22

33
import re
4-
from enum import Enum
4+
from enum import StrEnum
55
from typing import Any
66

77
from .model import Policy, Predicate, Rule
88

99
_MISSING = object()
1010

1111

12-
class Verdict(str, Enum):
12+
class Verdict(StrEnum):
1313
ALLOW = "allow"
1414
REQUIRE_APPROVAL = "require_approval"
1515

@@ -39,15 +39,23 @@ def match_predicate(predicate: Predicate, args: dict[str, Any]) -> bool:
3939
return False
4040
pv = predicate.value
4141
match predicate.op:
42-
case "eq": return val == pv
43-
case "neq": return val != pv
44-
case "in": return val in (pv or [])
45-
case "not_in": return val not in (pv or [])
42+
case "eq":
43+
return val == pv
44+
case "neq":
45+
return val != pv
46+
case "in":
47+
return val in (pv or [])
48+
case "not_in":
49+
return val not in (pv or [])
4650
# `regex` is re.search (substring match). Anchor with ^...$ for full-match.
47-
case "regex": return isinstance(val, str) and re.search(pv, val) is not None
48-
case "contains": return isinstance(val, (str, list, tuple, set)) and pv in val
49-
case "gt": return val > pv
50-
case "lt": return val < pv
51+
case "regex":
52+
return isinstance(val, str) and re.search(pv, val) is not None
53+
case "contains":
54+
return isinstance(val, (str, list, tuple, set)) and pv in val
55+
case "gt":
56+
return val > pv
57+
case "lt":
58+
return val < pv
5159
return False
5260

5361

@@ -57,8 +65,9 @@ def match_rule(rule: Rule, tool_name: str, args: dict[str, Any]) -> bool:
5765
return all(match_predicate(p, args) for p in rule.when)
5866

5967

60-
def find_matching_rule(tool_name: str, args: dict[str, Any],
61-
policy: Policy) -> Rule | None:
68+
def find_matching_rule(
69+
tool_name: str, args: dict[str, Any], policy: Policy
70+
) -> Rule | None:
6271
for rule in policy.rules:
6372
if match_rule(rule, tool_name, args):
6473
return rule
@@ -70,6 +79,8 @@ def evaluate(tool_name: str, args: dict[str, Any], policy: Policy) -> Verdict:
7079
return Verdict.ALLOW
7180
if find_matching_rule(tool_name, args, policy) is not None:
7281
return Verdict.REQUIRE_APPROVAL
73-
return (Verdict.REQUIRE_APPROVAL
74-
if policy.default_action == "require_approval"
75-
else Verdict.ALLOW)
82+
return (
83+
Verdict.REQUIRE_APPROVAL
84+
if policy.default_action == "require_approval"
85+
else Verdict.ALLOW
86+
)

src/ha_mcp/policy/handlers.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,30 @@ async def put_config(request: Request) -> JSONResponse:
3636
return JSONResponse({"saved": True})
3737

3838
async def get_pending(_: Request) -> JSONResponse:
39-
return JSONResponse({"pending": [
39+
return JSONResponse(
4040
{
41-
"token": e.token,
42-
"tool_name": e.tool_name,
43-
"args_preview": e.args_preview,
44-
"created_at": e.created_at.isoformat(),
45-
"expires_at": e.expires_at.isoformat(),
41+
"pending": [
42+
{
43+
"token": e.token,
44+
"tool_name": e.tool_name,
45+
"args_preview": e.args_preview,
46+
"created_at": e.created_at.isoformat(),
47+
"expires_at": e.expires_at.isoformat(),
48+
}
49+
for e in queue.list_pending()
50+
]
4651
}
47-
for e in queue.list_pending()
48-
]})
52+
)
4953

5054
async def post_approve(request: Request) -> JSONResponse:
5155
try:
5256
body = await request.json()
5357
except (ValueError, TypeError):
5458
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
5559
if not isinstance(body, dict):
56-
return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
60+
return JSONResponse(
61+
{"error": "body must be a JSON object"}, status_code=400
62+
)
5763
token = body.get("token")
5864
if not token or queue.get(token) is None:
5965
return JSONResponse({"error": "unknown token"}, status_code=404)
@@ -68,7 +74,9 @@ async def post_deny(request: Request) -> JSONResponse:
6874
except (ValueError, TypeError):
6975
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
7076
if not isinstance(body, dict):
71-
return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
77+
return JSONResponse(
78+
{"error": "body must be a JSON object"}, status_code=400
79+
)
7280
token = body.get("token")
7381
if not token or queue.get(token) is None:
7482
return JSONResponse({"error": "unknown token"}, status_code=404)

src/ha_mcp/policy/middleware.py

Lines changed: 60 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717

1818
# Toolsearch proxy meta-tools — always pass through; the inner real-tool
1919
# call re-enters the middleware via ctx.fastmcp.call_tool() and gets gated there.
20-
PROXY_META_TOOLS = frozenset({
21-
"ha_call_read_tool",
22-
"ha_call_write_tool",
23-
"ha_call_delete_tool",
24-
"ha_search_tools",
25-
})
20+
PROXY_META_TOOLS = frozenset(
21+
{
22+
"ha_call_read_tool",
23+
"ha_call_write_tool",
24+
"ha_call_delete_tool",
25+
"ha_search_tools",
26+
}
27+
)
2628

2729

2830
class PolicyMiddleware(Middleware):
@@ -65,24 +67,33 @@ async def on_call_tool(
6567
existing = self._queue.find(name, args_hash)
6668
if existing and existing.decision == "approved":
6769
self._queue.consume_and_maybe_remember(
68-
existing, remember_minutes=rule.remember_minutes if rule else 0,
70+
existing,
71+
remember_minutes=rule.remember_minutes if rule else 0,
6972
)
7073
return await call_next(context)
7174
if existing and existing.decision == "denied":
7275
self._queue.remove(existing.token)
7376
raise self._denied_error()
7477

7578
pending = existing or self._queue.create(
76-
name, args_hash, args, ttl_minutes=policy.approval_ttl_minutes,
79+
name,
80+
args_hash,
81+
args,
82+
ttl_minutes=policy.approval_ttl_minutes,
7783
)
7884
approval_url = self._approval_url_builder(pending.token)
7985

80-
wait = self._wait_override if self._wait_override is not None else policy.wait_seconds
86+
wait = (
87+
self._wait_override
88+
if self._wait_override is not None
89+
else policy.wait_seconds
90+
)
8191
await self._wait_for_decision(context, pending, approval_url, wait)
8292

8393
if pending.decision == "approved":
8494
self._queue.consume_and_maybe_remember(
85-
pending, remember_minutes=rule.remember_minutes if rule else 0,
95+
pending,
96+
remember_minutes=rule.remember_minutes if rule else 0,
8697
)
8798
return await call_next(context)
8899
if pending.decision == "denied":
@@ -101,7 +112,8 @@ async def _wait_for_decision(
101112
deadline = anyio.current_time() + wait_seconds
102113
while anyio.current_time() < deadline and pending.decision == "pending":
103114
await self._safe_report_progress(
104-
context, f"Awaiting user approval — open {approval_url}")
115+
context, f"Awaiting user approval — open {approval_url}"
116+
)
105117
remaining = deadline - anyio.current_time()
106118
if remaining <= 0:
107119
break
@@ -118,33 +130,43 @@ async def _safe_report_progress(context: MiddlewareContext, message: str) -> Non
118130

119131
@staticmethod
120132
def _denied_error() -> ToolError:
121-
return ToolError(json.dumps({
122-
"success": False,
123-
"error": {
124-
"code": "USER_DENIED",
125-
"message": "User explicitly denied this tool call.",
126-
"suggestions": ["Do not retry without confirming with the user first."],
127-
},
128-
}))
133+
return ToolError(
134+
json.dumps(
135+
{
136+
"success": False,
137+
"error": {
138+
"code": "USER_DENIED",
139+
"message": "User explicitly denied this tool call.",
140+
"suggestions": [
141+
"Do not retry without confirming with the user first."
142+
],
143+
},
144+
}
145+
)
146+
)
129147

130148
def _pending_error(self, pending: PendingApproval, approval_url: str) -> ToolError:
131149
remaining = int((pending.expires_at - pending.created_at).total_seconds())
132-
return ToolError(json.dumps({
133-
"success": False,
134-
"error": {
135-
"code": "USER_APPROVAL_REQUIRED",
136-
"message": (
137-
f"User approval required. Open {approval_url} to review the "
138-
"exact call and approve. Re-call this tool with the same "
139-
"arguments after the user approves."
140-
),
141-
"context": {
142-
"approve_url": approval_url,
143-
"expires_in_seconds": remaining,
144-
},
145-
"suggestions": [
146-
"Tell the user to click the approval link.",
147-
"Re-call this tool with the same arguments after the user approves.",
148-
],
149-
},
150-
}))
150+
return ToolError(
151+
json.dumps(
152+
{
153+
"success": False,
154+
"error": {
155+
"code": "USER_APPROVAL_REQUIRED",
156+
"message": (
157+
f"User approval required. Open {approval_url} to review the "
158+
"exact call and approve. Re-call this tool with the same "
159+
"arguments after the user approves."
160+
),
161+
"context": {
162+
"approve_url": approval_url,
163+
"expires_in_seconds": remaining,
164+
},
165+
"suggestions": [
166+
"Tell the user to click the approval link.",
167+
"Re-call this tool with the same arguments after the user approves.",
168+
],
169+
},
170+
}
171+
)
172+
)

tests/src/unit/policy/test_approval_queue.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from datetime import UTC, datetime, timedelta
22

3+
import anyio
34
import pytest
45

56
from ha_mcp.policy.approval_queue import (
@@ -46,7 +47,6 @@ def test_remember_cache_expired():
4647

4748

4849
# --- appended for Task 2.2: pending-entry lifecycle ---
49-
import anyio
5050

5151

5252
def test_create_returns_pending_entry():

0 commit comments

Comments
 (0)