Skip to content

Commit efe3918

Browse files
feat!: rework tool security policy semantics (ANY-match) + developer tools to drive the settings UI (#1993)
* feat: extend developer tools to drive Tools, security policies, and backups Extend the two existing ha_dev_* developer tools so they can drive every toggle in the web settings UI, not just the Server Settings matrix. ha_dev_manage_settings gains list_tools, set_tool (enable/disable/pin, LLM-API exposure, per-tool security gate), get_policy, set_policy, get_backup_config, and set_backup_config. ha_dev_manage_server gains list_pending, approve, and deny for the live security-policy approval queue. Each action reuses the same persistence and validation as the web settings handlers -- shared apply_backup_config, the env-pin and BPS-lock guards, and load_policy/save_policy with its optimistic-concurrency version bump -- so the tools and the web UI stay in lockstep. Supported on the embedded, add-on, and container deployments (full-server processes with a live tool registry and approval queue); the stdio settings sidecar returns a clear error for the registry/queue-dependent actions. No new tools are added and no custom-component change is needed (server package only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review findings — shared write lock, framing, list_tools, set_policy Bundle the Codex + agent review fixes into the developer-tools PR: - Shared config/policy write lock (utils/config_write_lock) held around every tool_config.json / tool_policy.json read-modify-write in the web save handlers (_save_tools, policy _put_config) AND the developer tool, so concurrent writers can't lose each other's update. - _atomic_write_json uses a unique temp file (mkstemp) so concurrent writers to the same path don't collide on a shared .tmp. - set_policy: version-CAS via the COERCED model version ("3" == 3); "won't enforce" warning when the engine is off; runs under the shared lock. - list_tools: report feature-gate availability (available/disabled_by) so stubs aren't mislabeled enabled, and live-vs-configured policy enforcement (policies_live) via approval_queue presence. - set_tool: surface a partial commit when the gate write fails after the state/LLM-API change already persisted. - Correct the "sidecar" framing everywhere: _server is never None in a real deployment; a missing approval queue means policies were off at startup. - Share the auto-backup field/origin/editable matrix between the web handler and the dev tool; gate toggle + _gated_tool_names key on the bare rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover new dev-tool behaviors (live registry, remember-cache, addon backup, partial commit) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: each policy condition is its own rule (ANY-match gating) The policy editor packed every condition into one rule with predicates AND-ed ("ALL conditions match"). Change it so each condition persists as its OWN rule under the same tool_name: the card collapses a tool s rules into one editing view and re-expands to one rule per condition on save, and the evaluator (which already ORs across rules) then gates if ANY condition matches. No conditions = one bare rule (always). Copy updated to "ANY" (en/de/ru). removePolicyRule drops all of a tool s rules; the Tools-tab gate toggle + gatedTools key on the bare unconditional rule. Adds jsdom tests for expand + enable-direction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: ANY-match policy gating (evaluator unit + e2e approval-flow) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: accept JSON-string dict args for set_policy/set_backup_config Live testing on an embedded server surfaced that the policy/backup dict params rejected a JSON-encoded string ("expected a JSON object"), so set_policy/set_backup_config were uncallable from MCP clients that stringify object args. Add JSON_STRING_COERCION (same BeforeValidator ha_call_service data uses) so a JSON string is parsed into the dict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat!: migrate pre-ANY policies (AND conditions become OR rules) BREAKING CHANGE: the pre-#1993 policy editor packed every condition into one rule with the predicates AND-ed ("require approval when ALL conditions match"). A one-time startup migration now splits every multi-predicate rule of an unstamped tool_policy.json into one rule per predicate — old AND conditions become OR rules, so gating triggers MORE than before (the fail-safe direction) and enforcement matches the editor's new "ANY condition matches" copy instead of silently diverging. The file is stamped with schema_version so the migration runs once; multi-predicate rules authored after the upgrade (a condition with AND-ed sub-parameters) are preserved. The policy card now treats each RULE as one condition row: single-predicate rows are editable, multi-predicate rows display "p1 AND p2" and round-trip intact instead of being flattened. Migration runs even when policies are disabled so the file is correct whenever the feature is enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: stamp serving-server policy state into tools/list _meta (#1990) A client pointed at a different ha-mcp endpoint than the one the user configured rules on previously failed SILENTLY: calls executed ungated and nothing on the wire said "this server has zero rules" (#1990 — the add-on log shows no MCP traffic reached the configured server at all). Every tools/list entry now carries _meta.ha_mcp.policy = {enabled, live, rules, deployment}: the ACTUAL gating state of the server answering this connection, TTL-cached like the exposure stamp and best-effort so a read failure can never break tools/list. policy_live is wired from the server so "configured but not enforcing until restart" is also visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address Codex round 3 — approval-action exemption, name validation, rule order, cross-process lock - PolicyMiddleware exempts ha_dev_manage_server list_pending/approve/deny: gating queue management deadlocks by construction (approve would create a second pending entry instead of deciding the first). update_source/restart stay gateable. - set_tool validates the tool name against the live registry (incl. feature-gated stubs) before persisting; best-effort so a metadata failure never bricks the tool. - savePolicyRule replaces a tool s rules IN PLACE: rule order is behaviorally significant (first match supplies remember_minutes). - config_file_lock (flock/msvcrt) held inside every config/policy RMW section, so the version CAS also holds across processes (stdio sidecar vs MCP server). Best-effort on platforms without locking. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: harden policy editor + close remaining review gaps Address the pr-review-toolkit findings: a failed policy-card auto-save now toasts loudly and resyncs from the server instead of displaying a phantom security rule (the #1990 failure shape); per-condition remember_minutes survive unrelated edits (only touching the input rewrites them); backup-config writes take the shared cross-process write guard like every other config surface; the set_tool gate write version-checks before saving so a degraded file lock fails loud instead of clobbering; docstrings/i18n catch up (policy _meta stamp shape, lazy data_paths import note, gating exemption + token self-approval caveat, always/AND strings in en/de/ru). Coverage: middleware-level exemption pass-through, JSON-string coercion for the dev-tool policy/backup params, policy-card collapse rendering, and the migrate-before-enabled-check startup wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address maintainer review — off-loop file lock, wildcard-aware rule insertion - config_write_guard() enters/exits the cross-process file lock in a worker thread so a held or slow flock can't stall the event loop - a NEW tool rule / bare gate inserts before the first wildcard rule in all three writers (savePolicyRule, syncPolicyRule, _apply_gate_to_policy) - list_tools degrades with a warning on a corrupt tool_policy.json instead of reporting a clean no-gates policy - the ANY-match migration runs under config_file_lock(data_dir) and logs at ERROR naming the AND/ANY divergence on failure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TDz5APvM27J7ZHgRFLXQNg * test: drain the cancelled ticker via gather to satisfy the CodeQL quality gate CodeQL flags a bare `await task` inside contextlib.suppress as py/ineffectual-statement (the same FP is path-allowlisted for embedded_entry/embedded_server); gather(return_exceptions=True) drains the cancellation without growing the allowlist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TDz5APvM27J7ZHgRFLXQNg * docs: correct the migration lock-safety claim for OAuth/OIDC startup The migration's inline flock does land on a running loop in OAuth/OIDC mode (the server is constructed inside asyncio.run), not strictly "before the event loop" — but construction happens before anything is served and the sync constructor cannot await, so state the actual invariant: a held lock can only delay startup, never stall a served client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TDz5APvM27J7ZHgRFLXQNg --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e002244 commit efe3918

27 files changed

Lines changed: 3318 additions & 280 deletions

src/ha_mcp/llm_exposure.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
1515
The single source of truth travels **in-band**: :class:`LlmExposureMiddleware`
1616
stamps every ``tools/list`` entry with
17-
``_meta.ha_mcp = {"llm_api_exposed": bool, "pinned": bool}`` so the component
18-
(one more loopback MCP client) filters on data that can never drift from the
19-
server's settings, with zero extra round-trips. Stamping re-reads the
17+
``_meta.ha_mcp = {"llm_api_exposed": bool, "pinned": bool, "policy": {...}}``
18+
so the component (one more loopback MCP client) filters on data that can never
19+
drift from the server's settings, with zero extra round-trips. The ``policy``
20+
block reports the serving server's gating state (#1990 — see META_POLICY_KEY). Stamping re-reads the
2021
persisted settings behind a short coalescing cache (2s TTL), so settings-UI
2122
changes apply on the agent's next conversation turn without a restart.
2223
@@ -31,7 +32,7 @@
3132

3233
import logging
3334
import time
34-
from collections.abc import Mapping, Sequence
35+
from collections.abc import Callable, Mapping, Sequence
3536
from typing import TYPE_CHECKING, Any
3637

3738
from fastmcp.server.middleware import Middleware
@@ -50,6 +51,15 @@
5051
META_EXPOSED_KEY = "llm_api_exposed"
5152
META_PINNED_KEY = "pinned"
5253

54+
# Serving-server policy/identity block stamped alongside the per-tool keys
55+
# (#1990). A client (or a debugging agent) reading tools/list can see the
56+
# ACTUAL gating state of the server answering this connection — configured
57+
# flag, live middleware, and rule count — plus the deployment mode. Without
58+
# this, a client pointed at a different server than the one the user
59+
# configured rules on fails silently: calls execute ungated and nothing
60+
# anywhere says "this server has zero rules".
61+
META_POLICY_KEY = "policy"
62+
5363
# Key in tool_config.json holding the user's per-tool overrides
5464
# ({tool_name: bool}). Sparse on purpose: only tools the user explicitly
5565
# flipped are stored, so tools added by future releases keep getting their
@@ -153,9 +163,17 @@ class LlmExposureMiddleware(Middleware):
153163
is hidden or altered for regular MCP clients.
154164
"""
155165

156-
def __init__(self) -> None:
157-
"""Initialize the short-lived settings cache."""
166+
def __init__(self, policy_live: Callable[[], bool] | None = None) -> None:
167+
"""Initialize the short-lived settings cache.
168+
169+
``policy_live`` reports whether the gating middleware/queue are
170+
actually wired on this server (they only wire at startup); the
171+
stamp carries it so "configured but not enforcing until restart"
172+
is visible on the wire.
173+
"""
158174
self._cache: tuple[float, dict[str, bool], set[str]] | None = None
175+
self._policy_live = policy_live
176+
self._policy_cache: tuple[float, dict[str, Any]] | None = None
159177

160178
def _current_settings(self) -> tuple[dict[str, bool], set[str]]:
161179
"""Return (overrides, pinned) with a short TTL over the file reads."""
@@ -192,6 +210,47 @@ def _current_settings(self) -> tuple[dict[str, bool], set[str]]:
192210
self._cache = (now, overrides, pinned)
193211
return overrides, pinned
194212

213+
def _policy_block(self) -> dict[str, Any]:
214+
"""Serving-server policy/identity block (TTL-cached like the overrides).
215+
216+
Best-effort: a failure to read settings or the policy file stamps
217+
conservative values (enabled=False / rules=0) rather than breaking
218+
tools/list — the block is diagnostic metadata, not enforcement.
219+
"""
220+
now = time.monotonic()
221+
if (
222+
self._policy_cache is not None
223+
and now - self._policy_cache[0] < _OVERRIDES_TTL_SECONDS
224+
):
225+
return self._policy_cache[1]
226+
from ._version import is_embedded, is_running_in_addon
227+
228+
block: dict[str, Any] = {
229+
"enabled": False,
230+
"live": bool(self._policy_live()) if self._policy_live else False,
231+
"rules": 0,
232+
"deployment": (
233+
"embedded"
234+
if is_embedded()
235+
else ("addon" if is_running_in_addon() else "standalone")
236+
),
237+
}
238+
try:
239+
from .config import get_global_settings
240+
241+
block["enabled"] = bool(get_global_settings().enable_tool_security_policies)
242+
except Exception:
243+
logger.debug("policy stamp: settings read failed", exc_info=True)
244+
try:
245+
from .policy.persistence import load_policy
246+
from .utils.data_paths import get_data_dir
247+
248+
block["rules"] = len(load_policy(get_data_dir()).rules)
249+
except Exception:
250+
logger.debug("policy stamp: policy read failed", exc_info=True)
251+
self._policy_cache = (now, block)
252+
return block
253+
195254
async def on_list_tools(
196255
self,
197256
context: MiddlewareContext[mt.ListToolsRequest],
@@ -200,6 +259,7 @@ async def on_list_tools(
200259
"""Stamp ``_meta.ha_mcp`` on every tool in the list result."""
201260
tools = await call_next(context)
202261
overrides, pinned = self._current_settings()
262+
policy_block = self._policy_block()
203263

204264
stamped: list[Tool] = []
205265
for tool in tools:
@@ -209,6 +269,7 @@ async def on_list_tools(
209269
tool.name, tool.tags or set(), overrides
210270
)
211271
namespace[META_PINNED_KEY] = tool.name in pinned
272+
namespace[META_POLICY_KEY] = policy_block
212273
meta[META_NAMESPACE] = namespace
213274
stamped.append(tool.model_copy(update={"meta": meta}))
214275
return stamped

src/ha_mcp/policy/handlers.py

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from starlette.requests import Request
1212
from starlette.responses import JSONResponse
1313

14+
from ..utils.config_write_lock import config_write_guard
1415
from .approval_queue import ApprovalQueue
1516
from .model import Policy
1617
from .persistence import load_policy, save_policy
@@ -86,24 +87,29 @@ async def _put_config(
8687
# Optimistic concurrency: reject if the on-disk version moved
8788
# between this caller's GET and PUT. Returns the current policy
8889
# so the client can rebase if it wants to retry.
89-
current = load_policy(data_dir)
90-
if new_policy.version != current.version:
91-
return JSONResponse(
92-
{
93-
"error": "policy version mismatch — reload before saving",
94-
"current_version": current.version,
95-
"current_policy": current.model_dump(mode="json"),
96-
},
97-
status_code=409,
98-
)
99-
save_policy(data_dir, new_policy)
100-
# Drop the remember-cache only when rules actually changed.
101-
# Editing just wait_seconds / approval_ttl_minutes shouldn't
102-
# invalidate in-flight remembered approvals; only a rule change
103-
# could make a previously-approved call now want a different
104-
# outcome.
105-
if current.rules != new_policy.rules:
106-
queue.clear_remember_cache()
90+
# Serialize the version-check + save against the developer tool
91+
# (set_policy / set_tool) AND against other processes (the stdio
92+
# sidecar runs this same handler in its own process) so a concurrent
93+
# writer can't slip between the read and the write and lose an update.
94+
async with config_write_guard():
95+
current = load_policy(data_dir)
96+
if new_policy.version != current.version:
97+
return JSONResponse(
98+
{
99+
"error": "policy version mismatch — reload before saving",
100+
"current_version": current.version,
101+
"current_policy": current.model_dump(mode="json"),
102+
},
103+
status_code=409,
104+
)
105+
save_policy(data_dir, new_policy)
106+
# Drop the remember-cache only when rules actually changed.
107+
# Editing just wait_seconds / approval_ttl_minutes shouldn't
108+
# invalidate in-flight remembered approvals; only a rule change
109+
# could make a previously-approved call now want a different
110+
# outcome.
111+
if current.rules != new_policy.rules:
112+
queue.clear_remember_cache()
107113
return JSONResponse({"saved": True, "version": new_policy.version + 1})
108114

109115

src/ha_mcp/policy/middleware.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@
3535
}
3636
)
3737

38+
# ha_dev_manage_server actions that MANAGE the approval queue itself.
39+
# Gating these deadlocks by construction: with a wildcard (or
40+
# ha_dev_manage_server) rule in place, an MCP-only "approve" call would
41+
# itself require approval — creating a second pending entry instead of
42+
# deciding the first, so nothing can ever be approved through the tool.
43+
# Only the queue-management actions are exempt; update_source / restart
44+
# remain gateable like any other high-stakes action.
45+
_APPROVAL_MANAGEMENT_TOOL = "ha_dev_manage_server"
46+
_APPROVAL_MANAGEMENT_ACTIONS = frozenset({"list_pending", "approve", "deny"})
47+
48+
49+
def _is_approval_management(name: str, args: dict[str, Any]) -> bool:
50+
"""True for dev-tool calls that manage the approval queue itself."""
51+
return (
52+
name == _APPROVAL_MANAGEMENT_TOOL
53+
and args.get("action") in _APPROVAL_MANAGEMENT_ACTIONS
54+
)
55+
56+
57+
def _passes_ungated(name: str, args: dict[str, Any]) -> bool:
58+
"""Calls that must bypass gating: proxy meta-tools + queue management."""
59+
return name in PROXY_META_TOOLS or _is_approval_management(name, args)
60+
3861

3962
class PolicyMiddleware(Middleware):
4063
"""Gate tool calls against a Policy, blocking with progress heartbeats."""
@@ -78,7 +101,7 @@ async def on_call_tool(
78101
name = context.message.name
79102
args = context.message.arguments or {}
80103

81-
if name in PROXY_META_TOOLS:
104+
if _passes_ungated(name, args):
82105
return await call_next(context)
83106

84107
if evaluate(name, args, policy) != Verdict.REQUIRE_APPROVAL:

src/ha_mcp/policy/model.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@
1616
"eq", "neq", "in", "not_in", "regex", "contains", "exists", "gt", "lt"
1717
]
1818

19+
# Schema generation of the persisted policy file. Version 2 = ANY-match
20+
# condition semantics (PR #1993): each UI condition is its own rule; a rule's
21+
# predicates AND together (a condition with sub-parameters). Files WITHOUT the
22+
# marker were written under the pre-#1993 editor, which packed every condition
23+
# into one AND-ed rule — ``persistence.migrate_policy_any_semantics`` splits
24+
# those once at startup and stamps the file.
25+
POLICY_SCHEMA_VERSION = 2
26+
1927

2028
class Predicate(BaseModel):
2129
"""Single condition on a tool call's arguments (e.g. args.domain in [...])."""
@@ -103,6 +111,9 @@ class Policy(BaseModel):
103111
approval_ttl_minutes: int = Field(default=5, ge=1, le=60)
104112
rules: list[Rule] = Field(default_factory=list)
105113
version: int = Field(default=0, ge=0)
114+
# ANY-match schema marker (see POLICY_SCHEMA_VERSION). Detection of
115+
# unmigrated files reads the RAW json (this default would mask it).
116+
schema_version: int = Field(default=POLICY_SCHEMA_VERSION, ge=1)
106117

107118
@model_validator(mode="after")
108119
def _wait_must_be_less_than_ttl(self) -> "Policy":

src/ha_mcp/policy/persistence.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Atomic load/save for tool_policy.json (mirrors tool_config.json pattern in settings_ui/__init__.py)."""
22

33
import json
4+
import logging
45
import os
56
import tempfile
67
from pathlib import Path
78

89
from pydantic import ValidationError
910

10-
from .model import Policy
11+
from .model import Policy, Rule
12+
13+
logger = logging.getLogger(__name__)
1114

1215
POLICY_FILENAME = "tool_policy.json"
1316

@@ -26,6 +29,85 @@ def load_policy(data_dir: Path) -> Policy:
2629
raise ValueError(f"tool_policy.json failed schema validation: {e}") from e
2730

2831

32+
def migrate_policy_any_semantics(data_dir: Path) -> bool:
33+
"""One-time migration of a pre-ANY policy file (PR #1993). Returns True on write.
34+
35+
The pre-#1993 editor packed every UI condition into ONE rule with the
36+
predicates AND-ed ("require approval when ALL conditions match"). The
37+
editor now writes one rule per condition and the UI reads "ANY condition
38+
matches" — so an untouched old file would silently enforce AND while the
39+
UI claims ANY. This splits every multi-predicate rule of an UNSTAMPED
40+
file into one single-predicate rule each (the explicit, documented
41+
breaking change: old AND conditions become OR, the more-restrictive
42+
direction) and stamps ``schema_version`` so the migration never re-runs —
43+
post-upgrade multi-predicate rules (a condition with AND-ed
44+
sub-parameters, hand-authored or via future UI) are left intact.
45+
46+
Detection reads the RAW json: the Policy model defaults
47+
``schema_version`` to current, which would mask an unstamped file.
48+
Missing or corrupt files are left alone (load_policy surfaces corruption).
49+
50+
The whole read-split-save runs under ``config_file_lock()`` so a
51+
concurrent writer in another process (the stdio settings sidecar) can't
52+
interleave with the migration's read-modify-write. The blocking lock is
53+
taken inline: this runs once during server construction, before anything
54+
is served. The sync entry points construct pre-loop; OAuth/OIDC construct
55+
on a just-started ``asyncio.run`` loop (``_run_oauth_server`` /
56+
``_run_oidc_server``) where the sync constructor cannot await — but no
57+
other task is scheduled on that loop yet, so a held lock can only delay
58+
startup, never stall a served client.
59+
"""
60+
from ..utils.config_write_lock import config_file_lock
61+
62+
with config_file_lock(data_dir):
63+
return _migrate_policy_any_semantics_locked(data_dir)
64+
65+
66+
def _migrate_policy_any_semantics_locked(data_dir: Path) -> bool:
67+
path = data_dir / POLICY_FILENAME
68+
try:
69+
raw = json.loads(path.read_text(encoding="utf-8"))
70+
except FileNotFoundError:
71+
return False
72+
except (OSError, json.JSONDecodeError):
73+
logger.warning("policy migration: cannot read %s; leaving as-is", path)
74+
return False
75+
if not isinstance(raw, dict) or raw.get("schema_version") is not None:
76+
return False
77+
try:
78+
policy = Policy.model_validate(raw)
79+
except ValidationError:
80+
logger.warning("policy migration: %s failed validation; leaving as-is", path)
81+
return False
82+
new_rules: list[Rule] = []
83+
split = 0
84+
for rule in policy.rules:
85+
if len(rule.when) > 1:
86+
split += 1
87+
new_rules.extend(
88+
Rule(
89+
tool_name=rule.tool_name,
90+
when=[predicate],
91+
remember_minutes=rule.remember_minutes,
92+
)
93+
for predicate in rule.when
94+
)
95+
else:
96+
new_rules.append(rule)
97+
save_policy(data_dir, policy.model_copy(update={"rules": new_rules}))
98+
logger.info(
99+
"Migrated %s to ANY-match condition semantics: split %d multi-condition "
100+
"rule(s) into one rule per condition (%d -> %d rules) and stamped "
101+
"schema_version. Conditions that previously ALL had to match now EACH "
102+
"gate on their own.",
103+
path,
104+
split,
105+
len(policy.rules),
106+
len(new_rules),
107+
)
108+
return True
109+
110+
29111
def save_policy(data_dir: Path, policy: Policy) -> None:
30112
# Bump version on every save so optimistic-concurrency callers can
31113
# detect mid-flight edits (PUT /api/policy/config 409s when the

src/ha_mcp/server.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,14 @@ def _initialize_server(self) -> None:
249249
# what it offers to Home Assistant conversation agents on it.
250250
from .llm_exposure import LlmExposureMiddleware
251251

252-
self.mcp.add_middleware(LlmExposureMiddleware())
252+
# policy_live: whether the gating middleware/queue actually wired at
253+
# startup — stamped so a client can distinguish "configured" from
254+
# "enforcing" on the very connection it is using (#1990).
255+
self.mcp.add_middleware(
256+
LlmExposureMiddleware(
257+
policy_live=lambda: getattr(self, "approval_queue", None) is not None
258+
)
259+
)
253260

254261
# Entity visibility enforce mode, INBOUND half (#2015) — always
255262
# installed, consults the live config per request (no-op unless
@@ -1022,6 +1029,25 @@ def _apply_tool_security_policies(self) -> None:
10221029
UI take effect immediately without restart and without a stale
10231030
in-memory cache.
10241031
"""
1032+
# One-time ANY-match schema migration (PR #1993) runs even when
1033+
# policies are disabled, so the file already matches the editor's
1034+
# ANY semantics whenever the user turns the feature on. Never
1035+
# blocks startup.
1036+
try:
1037+
from .policy.persistence import migrate_policy_any_semantics
1038+
from .utils.data_paths import get_data_dir as _get_data_dir
1039+
1040+
migrate_policy_any_semantics(_get_data_dir())
1041+
except Exception:
1042+
logger.error(
1043+
"tool_policy.json ANY-match migration failed; continuing. The "
1044+
"file may still carry pre-ANY semantics: multi-condition rules "
1045+
"will gate only when ALL conditions match, while the policy "
1046+
"editor presents them as ANY-match. Fix the file (or re-save "
1047+
"the policy in the settings UI) and restart.",
1048+
exc_info=True,
1049+
)
1050+
10251051
if not self.settings.enable_tool_security_policies:
10261052
return
10271053

0 commit comments

Comments
 (0)