keywright-server 0.0.1__py3-none-any.whl
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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Nudges: an agent asking for the human in the conversation.
|
|
2
|
+
|
|
3
|
+
An approval request is the agent asking to *do* something and waiting on the answer. A nudge is the
|
|
4
|
+
opposite shape: the agent has a question only the human can settle, and the answer comes back in the
|
|
5
|
+
conversation, not through Keywright. So a nudge grants nothing, blocks nothing and is never gated by
|
|
6
|
+
policy - it is a push notification plus a card that stays on the dashboard until the human clears it.
|
|
7
|
+
|
|
8
|
+
What it does need is a limit. A ping costs the human an interruption, so an agent gets one per
|
|
9
|
+
minute and may leave at most a handful outstanding; repeats of the same question are folded into
|
|
10
|
+
the nudge already on the phone rather than buzzing again.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import select, update
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
|
|
22
|
+
from . import actions, audit, events
|
|
23
|
+
from .config import settings
|
|
24
|
+
from .models import Agent, Nudge, now
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("keywright.nudges")
|
|
27
|
+
|
|
28
|
+
COOLDOWN_S = 60 # between two different pings from one agent
|
|
29
|
+
MAX_OPEN = 5 # outstanding pings one agent may leave on the dashboard
|
|
30
|
+
MAX_MESSAGE = 1000
|
|
31
|
+
MAX_CONTEXT = 200
|
|
32
|
+
DISMISS = "KWN_DISMISS_" # phone button: KWN_DISMISS_<nudge id>.<mac>
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Ping(NamedTuple):
|
|
36
|
+
"""What happened to an attempt to ping the human."""
|
|
37
|
+
|
|
38
|
+
nudge: Nudge | None
|
|
39
|
+
status: str # sent | duplicate | throttled | backlog
|
|
40
|
+
retry_after: int = 0
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def delivered(self) -> bool:
|
|
44
|
+
return self.status in ("sent", "duplicate")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dismiss_action(nudge_id: str) -> str:
|
|
48
|
+
return actions.sign(DISMISS + nudge_id)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_dismiss_action(action: str) -> str | None:
|
|
52
|
+
"""The nudge ID a phone button carries, or None if it is not one of ours."""
|
|
53
|
+
body = actions.verify(action)
|
|
54
|
+
return body[len(DISMISS):] or None if body and body.startswith(DISMISS) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def open_nudges(db: AsyncSession, agent_id: str | None = None) -> list[Nudge]:
|
|
58
|
+
q = select(Nudge).where(Nudge.cleared_at.is_(None)).order_by(Nudge.created_at.desc())
|
|
59
|
+
if agent_id:
|
|
60
|
+
q = q.where(Nudge.agent_id == agent_id)
|
|
61
|
+
return list((await db.execute(q)).scalars().all())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def create(db: AsyncSession, agent: Agent, session_id: str | None, message: str,
|
|
65
|
+
context: str = "") -> Ping:
|
|
66
|
+
"""Ask for the human. Returns what became of the ask; the caller tells the agent."""
|
|
67
|
+
message = message.strip()[:MAX_MESSAGE]
|
|
68
|
+
context = context.strip()[:MAX_CONTEXT]
|
|
69
|
+
if not message:
|
|
70
|
+
raise ValueError("A nudge needs a message: say what you need from them.")
|
|
71
|
+
at = now()
|
|
72
|
+
mine = await open_nudges(db, agent.id)
|
|
73
|
+
same = next((n for n in mine if n.message == message and n.session_id == session_id), None)
|
|
74
|
+
if same is not None:
|
|
75
|
+
# The same question, still on their phone. Buzzing again would not add anything.
|
|
76
|
+
return Ping(same, "duplicate")
|
|
77
|
+
if len(mine) >= MAX_OPEN:
|
|
78
|
+
return Ping(mine[0] if mine else None, "backlog")
|
|
79
|
+
last = mine[0].created_at if mine else None
|
|
80
|
+
if last is not None and (at - last).total_seconds() < COOLDOWN_S:
|
|
81
|
+
return Ping(mine[0], "throttled", int(COOLDOWN_S - (at - last).total_seconds()) + 1)
|
|
82
|
+
|
|
83
|
+
nudge = Nudge(agent_id=agent.id, session_id=session_id, message=message, context=context, created_at=at)
|
|
84
|
+
db.add(nudge)
|
|
85
|
+
await db.commit()
|
|
86
|
+
await db.refresh(nudge)
|
|
87
|
+
await audit.record(db, tool="attention", decision="sent", agent=agent, session_id=session_id,
|
|
88
|
+
detail={"nudge_id": nudge.id, "message": message, "context": context})
|
|
89
|
+
from .notify.manager import manager
|
|
90
|
+
|
|
91
|
+
await manager.notify_nudge(nudge, agent)
|
|
92
|
+
events.publish("nudge.created", id=nudge.id)
|
|
93
|
+
return Ping(nudge, "sent")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def clear(db: AsyncSession, nudge_id: str, via: str) -> Nudge | None:
|
|
97
|
+
"""Mark a nudge answered and take it off the phone. Idempotent: two taps clear it once."""
|
|
98
|
+
changed = await db.execute(update(Nudge).where(
|
|
99
|
+
Nudge.id == nudge_id, Nudge.cleared_at.is_(None),
|
|
100
|
+
).values(cleared_at=now(), cleared_via=via).execution_options(synchronize_session=False))
|
|
101
|
+
await db.commit()
|
|
102
|
+
nudge = await db.get(Nudge, nudge_id, populate_existing=True)
|
|
103
|
+
if nudge is None or changed.rowcount != 1:
|
|
104
|
+
return nudge
|
|
105
|
+
await audit.record(db, tool="attention.clear", decision="cleared", agent=nudge.agent,
|
|
106
|
+
session_id=nudge.session_id, detail={"nudge_id": nudge.id, "via": via})
|
|
107
|
+
from .notify.manager import manager
|
|
108
|
+
|
|
109
|
+
await manager.nudge_cleared(nudge)
|
|
110
|
+
events.publish("nudge.cleared", id=nudge.id)
|
|
111
|
+
return nudge
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def expire_stale(db: AsyncSession) -> int:
|
|
115
|
+
"""Nobody is still waiting on a day-old question. Clear it so the phone and dashboard agree."""
|
|
116
|
+
cutoff = now() - timedelta(seconds=settings.nudge_ttl_s)
|
|
117
|
+
stale = (await db.execute(select(Nudge.id).where(
|
|
118
|
+
Nudge.cleared_at.is_(None), Nudge.created_at <= cutoff))).scalars().all()
|
|
119
|
+
for nudge_id in stale:
|
|
120
|
+
await clear(db, nudge_id, via="expired")
|
|
121
|
+
return len(stale)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Omada Open API tools, exposed through the existing MCP approval boundary.
|
|
2
|
+
|
|
3
|
+
Only the broker sees client credentials and access tokens. Endpoints are fixed:
|
|
4
|
+
tool arguments cannot select an origin, API path, HTTP method, or auth headers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import ssl
|
|
13
|
+
import time
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from typing import Annotated, Any
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import mcp_types as types
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OmadaError(ValueError):
|
|
24
|
+
"""Safe to display or audit; never includes controller response bodies."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Identifier = Annotated[str, Field(strict=True, pattern=r"^[A-Za-z0-9_-]{1,128}$")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Page(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
32
|
+
page: int = Field(default=1, ge=1)
|
|
33
|
+
page_size: int = Field(default=100, ge=1, le=1000)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SitePage(Page):
|
|
37
|
+
site_id: Identifier
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class APDetails(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
42
|
+
site_id: Identifier
|
|
43
|
+
ap_mac: Annotated[str, Field(strict=True, pattern=r"^[0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5}$")]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
OPERATIONS = {
|
|
47
|
+
"omada_list_sites": (Page, "sites", "List accessible Omada sites, one page at a time."),
|
|
48
|
+
"omada_list_devices": (SitePage, "sites/{site_id}/devices",
|
|
49
|
+
"List devices (including routers), status and inventory in an Omada site."),
|
|
50
|
+
"omada_get_ap": (APDetails, "sites/{site_id}/aps/{ap_mac}",
|
|
51
|
+
"Read AP details, including its reported wireless mesh uplink."),
|
|
52
|
+
"omada_list_clients": (SitePage, "sites/{site_id}/clients",
|
|
53
|
+
"List clients in an Omada site, one page at a time."),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Short-lived bearer tokens stay in memory. The fingerprint includes the connection
|
|
57
|
+
# and credentials, so editing a resource never reuses the previous identity's token.
|
|
58
|
+
_tokens: OrderedDict[str, tuple[float, str]] = OrderedDict()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_config(config: dict[str, Any]) -> None:
|
|
62
|
+
url = config.get("url", "")
|
|
63
|
+
try:
|
|
64
|
+
parsed = urlsplit(url)
|
|
65
|
+
valid = (parsed.scheme == "https" and parsed.hostname and parsed.port != 0
|
|
66
|
+
and not parsed.username and not parsed.password
|
|
67
|
+
and parsed.path in ("", "/") and not parsed.query and not parsed.fragment
|
|
68
|
+
and not any(c.isspace() for c in url) and "\\" not in url)
|
|
69
|
+
except ValueError:
|
|
70
|
+
valid = False
|
|
71
|
+
if not valid:
|
|
72
|
+
raise OmadaError("Use the HTTPS Interface Access Address, including its port, without an API path.")
|
|
73
|
+
for key, label in (("omada_id", "Omada ID"), ("client_id", "Client ID")):
|
|
74
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", config.get(key, "")):
|
|
75
|
+
raise OmadaError(f"{label} must contain only letters, numbers, underscores and hyphens.")
|
|
76
|
+
if not config.get("client_secret") or config["client_secret"] == "***":
|
|
77
|
+
raise OmadaError("An Omada Client Secret is required.")
|
|
78
|
+
if not isinstance(config.get("verify_tls", True), bool):
|
|
79
|
+
raise OmadaError("TLS verification must be enabled or disabled explicitly.")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_tools() -> list[types.Tool]:
|
|
83
|
+
return [types.Tool(name=name, description=description, input_schema=model.model_json_schema(),
|
|
84
|
+
annotations=types.ToolAnnotations(read_only_hint=True, destructive_hint=False,
|
|
85
|
+
idempotent_hint=True))
|
|
86
|
+
for name, (model, _, description) in OPERATIONS.items()]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _result(response: httpx.Response) -> Any:
|
|
90
|
+
if not response.is_success:
|
|
91
|
+
raise OmadaError(f"Omada returned HTTP {response.status_code}; check the address and app permissions.")
|
|
92
|
+
try:
|
|
93
|
+
payload = response.json()
|
|
94
|
+
except ValueError:
|
|
95
|
+
raise OmadaError("Omada returned invalid JSON; check the Interface Access Address.") from None
|
|
96
|
+
if not isinstance(payload, dict) or type(payload.get("errorCode")) is not int:
|
|
97
|
+
raise OmadaError("Omada returned an unexpected API response.")
|
|
98
|
+
if payload["errorCode"] != 0:
|
|
99
|
+
raise OmadaError(f"Omada API error {payload['errorCode']}; check app credentials, site privileges "
|
|
100
|
+
"and controller API compatibility.")
|
|
101
|
+
if "result" not in payload:
|
|
102
|
+
raise OmadaError("Omada response is missing its result.")
|
|
103
|
+
return payload["result"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _token(client: httpx.AsyncClient, config: dict[str, Any], key: str) -> str:
|
|
107
|
+
cached = _tokens.get(key)
|
|
108
|
+
if cached and time.monotonic() < cached[0]:
|
|
109
|
+
_tokens.move_to_end(key)
|
|
110
|
+
return cached[1]
|
|
111
|
+
result = _result(await client.post(config["url"].rstrip("/") + "/openapi/authorize/token",
|
|
112
|
+
params={"grant_type": "client_credentials"},
|
|
113
|
+
json={"omadacId": config["omada_id"], "client_id": config["client_id"],
|
|
114
|
+
"client_secret": config["client_secret"]}))
|
|
115
|
+
if not isinstance(result, dict):
|
|
116
|
+
raise OmadaError("Omada returned an invalid token response.")
|
|
117
|
+
token, expires = result.get("accessToken"), result.get("expiresIn")
|
|
118
|
+
if (not isinstance(token, str) or not token or not token.isascii()
|
|
119
|
+
or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in token)
|
|
120
|
+
or type(expires) is not int or expires <= 0):
|
|
121
|
+
raise OmadaError("Omada returned an invalid access token or expiry.")
|
|
122
|
+
_tokens[key] = (time.monotonic() + max(0, min(expires, 7200) - 30), token)
|
|
123
|
+
_tokens.move_to_end(key)
|
|
124
|
+
while len(_tokens) > 128:
|
|
125
|
+
_tokens.popitem(last=False)
|
|
126
|
+
return token
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def request(config: dict[str, Any], tool: str, arguments: dict[str, Any]) -> Any:
|
|
130
|
+
if tool not in OPERATIONS:
|
|
131
|
+
raise OmadaError("Unknown Omada tool.")
|
|
132
|
+
model, path, _ = OPERATIONS[tool]
|
|
133
|
+
try:
|
|
134
|
+
args = model.model_validate(arguments)
|
|
135
|
+
except ValidationError:
|
|
136
|
+
raise OmadaError("Invalid Omada arguments; use the tool schema.") from None
|
|
137
|
+
validate_config(config)
|
|
138
|
+
verify: bool | ssl.SSLContext = config.get("verify_tls", True)
|
|
139
|
+
if verify and config.get("ca_bundle"):
|
|
140
|
+
try:
|
|
141
|
+
verify = ssl.create_default_context(cafile=config["ca_bundle"])
|
|
142
|
+
except (OSError, ssl.SSLError):
|
|
143
|
+
raise OmadaError("Could not load the configured Omada CA certificate bundle.") from None
|
|
144
|
+
key = hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
|
|
145
|
+
path_args = args.model_dump()
|
|
146
|
+
if isinstance(args, APDetails):
|
|
147
|
+
path_args["ap_mac"] = args.ap_mac.replace(":", "-").upper()
|
|
148
|
+
params = {"page": args.page, "pageSize": args.page_size} if isinstance(args, Page) else {}
|
|
149
|
+
url = (config["url"].rstrip("/") + "/openapi/v1/" + config["omada_id"] + "/"
|
|
150
|
+
+ path.format(**path_args))
|
|
151
|
+
try:
|
|
152
|
+
async with httpx.AsyncClient(verify=verify, timeout=30, follow_redirects=False, trust_env=False) as client:
|
|
153
|
+
for attempt in range(2):
|
|
154
|
+
token = await _token(client, config, key)
|
|
155
|
+
response = await client.get(url, params=params,
|
|
156
|
+
headers={"Authorization": f"AccessToken={token}"})
|
|
157
|
+
if response.status_code == 401:
|
|
158
|
+
_tokens.pop(key, None)
|
|
159
|
+
if attempt == 0:
|
|
160
|
+
continue
|
|
161
|
+
return _result(response)
|
|
162
|
+
except httpx.HTTPError:
|
|
163
|
+
raise OmadaError("Could not reach the Omada API; check connectivity and TLS certificate settings.") from None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def call_tool(config: dict[str, Any], tool: str, arguments: dict[str, Any]) -> types.CallToolResult:
|
|
167
|
+
try:
|
|
168
|
+
result = await request(config, tool, arguments)
|
|
169
|
+
except OmadaError as e:
|
|
170
|
+
return types.CallToolResult(is_error=True, content=[types.TextContent(type="text", text=str(e))])
|
|
171
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=json.dumps(result))])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def probe(config: dict[str, Any]) -> tuple[bool, str]:
|
|
175
|
+
try:
|
|
176
|
+
await request(config, "omada_list_sites", {"page_size": 1})
|
|
177
|
+
except OmadaError as e:
|
|
178
|
+
return False, str(e)
|
|
179
|
+
return True, "Connected to Omada Open API; site listing succeeded."
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""The decision engine.
|
|
2
|
+
|
|
3
|
+
For every required pattern, in order:
|
|
4
|
+
1. a matching `deny` policy -> denied
|
|
5
|
+
2. an active grant for this agent -> allowed (once-grants are consumed in the same transaction)
|
|
6
|
+
3. a matching `allow` policy -> allowed
|
|
7
|
+
4. the resource's default mode -> open: allowed / locked: locked / approval: needs a request
|
|
8
|
+
|
|
9
|
+
An operation flagged high or critical by `hazards` skips 3 and 4 and, in 2, accepts only a once
|
|
10
|
+
grant bound to this exact operation: whatever standing access exists, a human has to approve that
|
|
11
|
+
command line, and the approval is used up by it. Critical operations never reach here; the tool
|
|
12
|
+
layer refuses them before asking.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
from keywright import patterns as pat
|
|
21
|
+
from keywright.hazards import blocks, worst
|
|
22
|
+
from sqlalchemy import select, update
|
|
23
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
24
|
+
|
|
25
|
+
from . import llm
|
|
26
|
+
from .access import binds_operation, constraint_label, grant_constraints, operation_matches, target_of
|
|
27
|
+
from .models import AccessRequest, Agent, Grant, Policy, Resource, now
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Verdict:
|
|
32
|
+
allowed: bool = False
|
|
33
|
+
status: str = "pending" # allow | deny | locked | pending | unknown
|
|
34
|
+
missing: list[str] = field(default_factory=list) # patterns needing approval
|
|
35
|
+
denied: list[str] = field(default_factory=list)
|
|
36
|
+
locked: list[str] = field(default_factory=list)
|
|
37
|
+
unknown: list[str] = field(default_factory=list) # no such resource
|
|
38
|
+
consumed_grants: list[str] = field(default_factory=list)
|
|
39
|
+
matched: dict[str, str] = field(default_factory=dict) # pattern -> what allowed it
|
|
40
|
+
bound: dict[str, str] = field(default_factory=dict) # pattern -> how far that grant or rule reaches
|
|
41
|
+
request_ids: list[str] = field(default_factory=list)
|
|
42
|
+
hazard: str | None = None # worst hazard level of the operation, if any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def evaluate(
|
|
46
|
+
db: AsyncSession,
|
|
47
|
+
agent: Agent,
|
|
48
|
+
session_id: str | None,
|
|
49
|
+
required: list[str],
|
|
50
|
+
*,
|
|
51
|
+
consume: bool = True,
|
|
52
|
+
operation: dict | None = None,
|
|
53
|
+
discovery: bool = False,
|
|
54
|
+
) -> Verdict:
|
|
55
|
+
"""Evaluate `required` for `agent`. With consume=True, matching once-grants are decremented
|
|
56
|
+
and the transaction is committed when everything is allowed."""
|
|
57
|
+
at = now()
|
|
58
|
+
if discovery and consume:
|
|
59
|
+
raise ValueError("Discovery cannot consume grants")
|
|
60
|
+
expected_hash = agent.token_hash
|
|
61
|
+
current = await db.get(Agent, agent.id, populate_existing=True)
|
|
62
|
+
if current is None or current.disabled or current.token_hash != expected_hash:
|
|
63
|
+
return Verdict(status="deny", denied=list(required))
|
|
64
|
+
policies = (
|
|
65
|
+
await db.execute(select(Policy).execution_options(populate_existing=True).where(
|
|
66
|
+
(Policy.agent_id == agent.id) | Policy.agent_id.is_(None)))
|
|
67
|
+
).scalars().all()
|
|
68
|
+
grants = (
|
|
69
|
+
await db.execute(select(Grant).execution_options(populate_existing=True).where(
|
|
70
|
+
Grant.agent_id == agent.id, Grant.revoked_at.is_(None)))
|
|
71
|
+
).scalars().all()
|
|
72
|
+
resources = {r.path_prefix: r for r in (await db.execute(
|
|
73
|
+
select(Resource).execution_options(populate_existing=True))).scalars().all()}
|
|
74
|
+
request_ids = {g.request_id for g in grants if g.scope == "once" and g.request_id}
|
|
75
|
+
requests = {r.id: r for r in (await db.execute(
|
|
76
|
+
select(AccessRequest).where(AccessRequest.id.in_(request_ids)))).scalars().all()} if request_ids else {}
|
|
77
|
+
|
|
78
|
+
v, to_consume = decide(
|
|
79
|
+
agent, session_id, required, policies=policies, grants=grants, resources=resources,
|
|
80
|
+
requests=requests, at=at, operation=operation, discovery=discovery,
|
|
81
|
+
)
|
|
82
|
+
if v.allowed and consume and to_consume:
|
|
83
|
+
# Conditional updates serialize competing calls in the database, across workers too.
|
|
84
|
+
# Consume each grant once per operation, even if it covers multiple required paths.
|
|
85
|
+
for gid in sorted({g.id for g in to_consume}):
|
|
86
|
+
changed = await db.execute(update(Grant).where(
|
|
87
|
+
Grant.id == gid, Grant.revoked_at.is_(None), Grant.uses_remaining > 0,
|
|
88
|
+
(Grant.expires_at.is_(None)) | (Grant.expires_at > now()),
|
|
89
|
+
).values(uses_remaining=Grant.uses_remaining - 1).execution_options(synchronize_session=False))
|
|
90
|
+
if changed.rowcount != 1:
|
|
91
|
+
await db.rollback()
|
|
92
|
+
await db.refresh(agent)
|
|
93
|
+
return Verdict(status="pending", missing=list(required))
|
|
94
|
+
v.consumed_grants.append(gid)
|
|
95
|
+
await db.commit()
|
|
96
|
+
return v
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def decide(
|
|
100
|
+
agent: Agent,
|
|
101
|
+
session_id: str | None,
|
|
102
|
+
required: list[str],
|
|
103
|
+
*,
|
|
104
|
+
policies: list[Policy],
|
|
105
|
+
grants: list[Grant],
|
|
106
|
+
resources: dict[str, Resource],
|
|
107
|
+
requests: dict[str, AccessRequest],
|
|
108
|
+
at: datetime,
|
|
109
|
+
operation: dict | None = None,
|
|
110
|
+
discovery: bool = False,
|
|
111
|
+
) -> tuple[Verdict, list[Grant]]:
|
|
112
|
+
"""Pure decision shared by live enforcement and history previews.
|
|
113
|
+
|
|
114
|
+
Inputs are the current agent's applicable policies/grants and a resource snapshot.
|
|
115
|
+
The returned grant list is a consumption plan; only evaluate() can spend it.
|
|
116
|
+
"""
|
|
117
|
+
if agent.disabled:
|
|
118
|
+
return Verdict(status="deny", denied=list(required)), []
|
|
119
|
+
v = Verdict(hazard=worst(operation.get("hazards")) if operation else None)
|
|
120
|
+
fresh_only = blocks(v.hazard)
|
|
121
|
+
|
|
122
|
+
def eligible(g: Grant, op: dict) -> bool:
|
|
123
|
+
req = requests.get(g.request_id)
|
|
124
|
+
if req and req.session_id and req.session_id != session_id:
|
|
125
|
+
return False
|
|
126
|
+
return discovery or operation_matches(grant_constraints(g, req), op)
|
|
127
|
+
|
|
128
|
+
to_consume: list[Grant] = []
|
|
129
|
+
for path in required:
|
|
130
|
+
rp = pat.resource_of(path)
|
|
131
|
+
res = resources.get(f"{rp[0]}/{rp[1]}") if rp else None
|
|
132
|
+
if res is None:
|
|
133
|
+
v.unknown.append(path)
|
|
134
|
+
continue
|
|
135
|
+
# Without an operation, rules bound to this target still apply; exact-operation rules do not.
|
|
136
|
+
op = operation if operation is not None else target_of(res)
|
|
137
|
+
deny_matches = pat.overlaps if "*" in path else pat.matches
|
|
138
|
+
allow_matches = pat.covers if "*" in path else pat.matches
|
|
139
|
+
if any(p.effect == "deny" and deny_matches(p.pattern, path)
|
|
140
|
+
and operation_matches(p.constraints, op) for p in policies):
|
|
141
|
+
v.denied.append(path)
|
|
142
|
+
continue
|
|
143
|
+
active = [g for g in grants if g.is_active(at, session_id)
|
|
144
|
+
and allow_matches(g.pattern, path) and eligible(g, op)
|
|
145
|
+
and (not fresh_only or (g.scope == "once" and binds_operation(grant_constraints(g, requests.get(g.request_id)))))]
|
|
146
|
+
if active:
|
|
147
|
+
# prefer grants that don't get used up, then the most specific one
|
|
148
|
+
active.sort(key=lambda g: (g.scope == "once", -pat.specificity(g.pattern)))
|
|
149
|
+
g = active[0]
|
|
150
|
+
v.matched[path] = f"grant:{g.id}"
|
|
151
|
+
v.bound[path] = constraint_label(grant_constraints(g, requests.get(g.request_id)))
|
|
152
|
+
if g.request_id and g.request_id not in v.request_ids:
|
|
153
|
+
v.request_ids.append(g.request_id)
|
|
154
|
+
if g.scope == "once":
|
|
155
|
+
to_consume.append(g)
|
|
156
|
+
continue
|
|
157
|
+
if fresh_only:
|
|
158
|
+
v.missing.append(path)
|
|
159
|
+
continue
|
|
160
|
+
allow = [p for p in policies if p.effect == "allow" and allow_matches(p.pattern, path)
|
|
161
|
+
and (discovery or operation_matches(p.constraints, op))]
|
|
162
|
+
if allow:
|
|
163
|
+
v.matched[path] = f"policy:{allow[0].id}"
|
|
164
|
+
v.bound[path] = constraint_label(allow[0].constraints)
|
|
165
|
+
if allow[0].request_id and allow[0].request_id not in v.request_ids:
|
|
166
|
+
v.request_ids.append(allow[0].request_id)
|
|
167
|
+
continue
|
|
168
|
+
if res.default_mode == "open":
|
|
169
|
+
v.matched[path] = "resource:open"
|
|
170
|
+
elif res.default_mode == "locked":
|
|
171
|
+
v.locked.append(path)
|
|
172
|
+
else:
|
|
173
|
+
v.missing.append(path)
|
|
174
|
+
|
|
175
|
+
if v.denied:
|
|
176
|
+
v.status = "deny"
|
|
177
|
+
elif v.unknown:
|
|
178
|
+
v.status = "unknown"
|
|
179
|
+
elif v.locked:
|
|
180
|
+
v.status = "locked"
|
|
181
|
+
elif v.missing:
|
|
182
|
+
v.status = "pending"
|
|
183
|
+
else:
|
|
184
|
+
v.status = "allow"
|
|
185
|
+
v.allowed = True
|
|
186
|
+
return v, to_consume
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def effective_access(db: AsyncSession, agent: Agent, session_id: str | None) -> list[dict]:
|
|
190
|
+
"""What `access_list_resources` shows: per resource, the agent's current standing."""
|
|
191
|
+
at = now()
|
|
192
|
+
out = []
|
|
193
|
+
resources = (await db.execute(select(Resource).order_by(Resource.kind, Resource.name))).scalars().all()
|
|
194
|
+
policies = (
|
|
195
|
+
await db.execute(select(Policy).execution_options(populate_existing=True).where(
|
|
196
|
+
(Policy.agent_id == agent.id) | Policy.agent_id.is_(None)))
|
|
197
|
+
).scalars().all()
|
|
198
|
+
grants = (
|
|
199
|
+
await db.execute(select(Grant).execution_options(populate_existing=True).where(
|
|
200
|
+
Grant.agent_id == agent.id, Grant.revoked_at.is_(None)))
|
|
201
|
+
).scalars().all()
|
|
202
|
+
legacy_ids = {g.request_id for g in grants if g.scope == "once" and not g.constraints and g.request_id}
|
|
203
|
+
legacy_requests = {r.id: r for r in (await db.execute(select(AccessRequest).where(
|
|
204
|
+
AccessRequest.id.in_(legacy_ids)))).scalars().all()} if legacy_ids else {}
|
|
205
|
+
for r in resources:
|
|
206
|
+
if r.config.get("adapter") == "knowledge":
|
|
207
|
+
# Collection names/descriptions are knowledge metadata too. Discover only collections
|
|
208
|
+
# with an available tool; every actual call is still gated independently.
|
|
209
|
+
from .knowledge_mcp import OPERATIONS
|
|
210
|
+
visible = False
|
|
211
|
+
for tool in OPERATIONS:
|
|
212
|
+
verdict = await evaluate(db, agent, session_id, [f"mcp/{r.name}/tools/{tool}"],
|
|
213
|
+
consume=False, discovery=True)
|
|
214
|
+
if verdict.status not in ("deny", "locked", "unknown"):
|
|
215
|
+
visible = True
|
|
216
|
+
break
|
|
217
|
+
if not visible:
|
|
218
|
+
continue
|
|
219
|
+
prefix = r.path_prefix
|
|
220
|
+
entry = {
|
|
221
|
+
"resource": prefix,
|
|
222
|
+
"kind": r.kind,
|
|
223
|
+
"default": r.default_mode,
|
|
224
|
+
"description": r.description,
|
|
225
|
+
**({"argument_keys": list(r.config.get("arg_keys") or []),
|
|
226
|
+
"bundles": {n: [f"mcp/{r.name}/{e}" for e in es] for n, es in (r.config.get("bundles") or {}).items()}}
|
|
227
|
+
if r.kind == "mcp" else {}),
|
|
228
|
+
**({"models": llm.model_paths(r), "limits_per_lease": llm.limits_of(r.config),
|
|
229
|
+
"agent_budget": llm.agent_budget_of(r.config),
|
|
230
|
+
"budgets_enforced": llm.enforces_budgets(r.config.get("health")),
|
|
231
|
+
"note": "Ask for llm/<gateway>/model/<model> patterns with access_request; an approval is a timed "
|
|
232
|
+
"lease with a spend ceiling, never once or always."}
|
|
233
|
+
if r.kind == "llm" else {}),
|
|
234
|
+
"allow_rules": sorted({p.pattern for p in policies if p.effect == "allow" and not p.constraints
|
|
235
|
+
and _touches(p.pattern, prefix)}),
|
|
236
|
+
"deny_rules": sorted({p.pattern for p in policies if p.effect == "deny" and not p.constraints
|
|
237
|
+
and _touches(p.pattern, prefix)}),
|
|
238
|
+
"constrained_rules": [
|
|
239
|
+
{"pattern": p.pattern, "effect": p.effect, "constraints": p.constraints}
|
|
240
|
+
for p in policies if p.constraints and _touches(p.pattern, prefix)
|
|
241
|
+
],
|
|
242
|
+
"grants": [
|
|
243
|
+
{
|
|
244
|
+
"pattern": g.pattern,
|
|
245
|
+
"scope": g.scope,
|
|
246
|
+
"expires_at": _iso(g.expires_at),
|
|
247
|
+
"uses_remaining": g.uses_remaining,
|
|
248
|
+
"constraints": grant_constraints(g, legacy_requests.get(g.request_id)),
|
|
249
|
+
}
|
|
250
|
+
for g in grants
|
|
251
|
+
if g.is_active(at, session_id) and _touches(g.pattern, prefix)
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
out.append(entry)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _touches(pattern: str, prefix: str) -> bool:
|
|
259
|
+
p = pattern.split("/")
|
|
260
|
+
q = prefix.split("/")
|
|
261
|
+
return all(a in ("*", "**") or a == b for a, b in zip(p[:2], q, strict=False)) or p[0] == "**"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _iso(d: datetime | None) -> str | None:
|
|
265
|
+
return d.isoformat() if d else None
|