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.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
@@ -0,0 +1,1126 @@
1
+ """Admin UI: server-rendered pages, plain forms, no JS dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import ipaddress
8
+ import logging
9
+ import os
10
+ import secrets
11
+ import shlex
12
+ from datetime import timedelta
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from fastapi import APIRouter, Depends, Form, Request, WebSocket, WebSocketDisconnect
17
+ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
18
+ from fastapi.templating import Jinja2Templates
19
+ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
20
+ from keywright.hazards import SEVERITIES
21
+ from keywright.hazards import blocks as hazard_blocks
22
+ from keywright.patterns import validate
23
+ from sqlalchemy import String, cast, desc, func, or_, select
24
+ from starlette.concurrency import run_in_threadpool
25
+
26
+ from .. import (
27
+ access_view,
28
+ approvals,
29
+ audit,
30
+ bundles,
31
+ events,
32
+ integrations,
33
+ inventory,
34
+ inventory_mcp,
35
+ llm,
36
+ mcp_oauth,
37
+ mcp_proxy,
38
+ nudges,
39
+ omada,
40
+ policy_preview,
41
+ result_inspection,
42
+ semantic,
43
+ ssh,
44
+ tool_access,
45
+ )
46
+ from ..access import (
47
+ binds_exactly,
48
+ constraint_label,
49
+ hazard_level,
50
+ hazards_of,
51
+ operation_detail,
52
+ operations,
53
+ path_scoped,
54
+ )
55
+ from ..config import settings
56
+ from ..db import session
57
+ from ..mcp_server import hash_token
58
+ from ..models import AccessRequest, Agent, AuditEvent, Grant, Nudge, Policy, Resource, now
59
+ from ..notify.base import operation_line, registry
60
+ from ..notify.manager import DEFAULT_BUTTONS, manager
61
+ from ..settings_store import get as get_setting
62
+ from ..settings_store import put as put_setting
63
+ from . import auth, mcp_oauth_ui, oidc
64
+ from .security import require_same_origin, safe_redirect, websocket_same_origin
65
+
66
+ log = logging.getLogger("keywright.ui")
67
+
68
+ router = APIRouter(dependencies=[Depends(require_same_origin)])
69
+ # Websockets never carry a method require_same_origin could check, so they live on their own
70
+ # router and do the origin check themselves.
71
+ ws_router = APIRouter()
72
+ templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
73
+ templates.env.filters["shellquote"] = shlex.quote
74
+ templates.env.filters["redact"] = audit._scrub
75
+ templates.env.globals.update(scope_labels=approvals.SCOPE_LABELS, scopes=approvals.SCOPES,
76
+ bundles_text=bundles.format_text, operation_detail=operation_detail,
77
+ operations=operations, binds_exactly=binds_exactly, path_scoped=path_scoped,
78
+ constraint_label=constraint_label, remaining=access_view.remaining,
79
+ decision_label=approvals.decision_label, hazards_of=hazards_of,
80
+ hazard_level=hazard_level, hazard_blocks=hazard_blocks,
81
+ operation_line=operation_line, jev_line=semantic.describe,
82
+ jev_effect_words=semantic.EFFECT_WORDS,
83
+ session_cap_minutes=settings.session_grant_ttl_s // 60)
84
+
85
+
86
+ def render(request: Request, name: str, **ctx: Any) -> HTMLResponse:
87
+ flash = request.cookies.get("kw_flash")
88
+ resp = templates.TemplateResponse(request, name, {"request": request, "flash": flash, "now": now(), **ctx})
89
+ if flash:
90
+ resp.delete_cookie("kw_flash")
91
+ return resp
92
+
93
+
94
+ def redirect(url: str, flash: str | None = None) -> RedirectResponse:
95
+ resp = RedirectResponse(safe_redirect(url), status_code=303)
96
+ if flash:
97
+ resp.set_cookie("kw_flash", flash[:500], max_age=30, httponly=True, samesite="lax")
98
+ return resp
99
+
100
+
101
+ # ---- auth pages ---------------------------------------------------------------------------------
102
+ @router.get("/", include_in_schema=False)
103
+ async def root():
104
+ return RedirectResponse("/ui", status_code=302)
105
+
106
+
107
+ @router.get("/ui/setup", response_class=HTMLResponse)
108
+ async def setup_get(request: Request):
109
+ if auth.password_is_set() or auth.oidc_enabled():
110
+ return redirect("/ui/login")
111
+ return render(request, "setup.html")
112
+
113
+
114
+ @router.post("/ui/setup")
115
+ async def setup_post(request: Request, password: str = Form(...), repeat: str = Form(...)):
116
+ if auth.password_is_set() or auth.oidc_enabled():
117
+ return redirect("/ui/login")
118
+ if not 8 <= len(password) <= 1024 or password != repeat:
119
+ return render(request, "setup.html", error="Passwords must match and be at least 8 characters.")
120
+ async with session() as db:
121
+ await auth.set_password(db, password)
122
+ return auth.login_response("/ui")
123
+
124
+
125
+ @router.get("/ui/login", response_class=HTMLResponse)
126
+ async def login_get(request: Request, next: str = "/ui", local: bool = False, logged_out: bool = False):
127
+ if await auth.authenticated(request):
128
+ return redirect(next)
129
+ if auth.oidc_enabled() and not local and not logged_out:
130
+ from urllib.parse import urlencode
131
+
132
+ return redirect("/ui/oidc/login?" + urlencode({"next": safe_redirect(next)}))
133
+ if not auth.password_is_set() and not auth.oidc_enabled():
134
+ return redirect("/ui/setup")
135
+ return render(request, "login.html", next=safe_redirect(next), oidc_enabled=auth.oidc_enabled(),
136
+ password_enabled=settings.auth_mode != "oidc" and auth.password_is_set(), logged_out=logged_out)
137
+
138
+
139
+ @router.post("/ui/login")
140
+ async def login_post(request: Request, password: str = Form(...), next: str = Form("/ui")):
141
+ if settings.auth_mode == "oidc":
142
+ return oidc.failure("Password login is disabled. Sign in with Authentik.", 403)
143
+ address = auth.limit_login(request)
144
+ if await run_in_threadpool(auth.check_password, password):
145
+ auth.login_succeeded(address)
146
+ return auth.login_response(next if next.startswith("/") else "/ui")
147
+ return render(request, "login.html", next=next, error="Wrong password.",
148
+ oidc_enabled=auth.oidc_enabled(), password_enabled=True)
149
+
150
+
151
+ @router.post("/ui/logout")
152
+ async def logout(request: Request):
153
+ await oidc.logout(request)
154
+ return auth.logout_response("/ui/login?logged_out=1")
155
+
156
+
157
+ # ---- dashboard & requests -----------------------------------------------------------------------
158
+ async def _waiting(db) -> dict[str, Any]:
159
+ """What is waiting on the human right now: the live region of the dashboard."""
160
+ pending = (await db.execute(
161
+ select(AccessRequest).where(AccessRequest.status == "pending").order_by(desc(AccessRequest.created_at))
162
+ )).scalars().all()
163
+ return {"pending": list(pending), "nudges": await nudges.open_nudges(db)}
164
+
165
+
166
+ async def _counts(db) -> dict[str, int]:
167
+ """The numbers on the stat tiles, cheap enough to recompute on every live event."""
168
+ at = now()
169
+ # Deliberately the same condition as _waiting: a request whose clock has run out but which the
170
+ # janitor has not swept yet is still a tile on the page, so it is still a number on the tile.
171
+ pending = await db.scalar(select(func.count()).select_from(AccessRequest).where(
172
+ AccessRequest.status == "pending"))
173
+ waiting = await db.scalar(select(func.count()).select_from(Nudge).where(Nudge.cleared_at.is_(None)))
174
+ grants = (await db.execute(select(Grant).where(
175
+ Grant.revoked_at.is_(None),
176
+ (Grant.expires_at.is_(None)) | (Grant.expires_at > at),
177
+ (Grant.uses_remaining.is_(None)) | (Grant.uses_remaining > 0),
178
+ ))).scalars().all()
179
+ # Grants from one approval are one lease to a human, exactly as the access overview groups them.
180
+ leases = {(g.agent_id, g.request_id or g.id) for g in grants if g.is_active(at, g.session_id)}
181
+ return {"pending": pending or 0, "nudges": waiting or 0, "leases": len(leases)}
182
+
183
+
184
+ @router.get("/ui", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
185
+ async def dashboard(request: Request):
186
+ async with session() as db:
187
+ await approvals.expire_stale(db)
188
+ await nudges.expire_stale(db)
189
+ waiting = await _waiting(db)
190
+ access = await access_view.overview(db)
191
+ recent = (await db.execute(select(AuditEvent).order_by(desc(AuditEvent.id)).limit(15))).scalars().all()
192
+ drifted = [r for r in (await db.execute(select(Resource).where(Resource.kind == "mcp"))).scalars().all()
193
+ if r.config.get("tools_drift")]
194
+ return render(request, "dashboard.html", recent=recent, notifier=manager, drifted=drifted, **waiting, **access)
195
+
196
+
197
+ @router.post("/ui/nudges/{nid}/dismiss", dependencies=[Depends(auth.require_admin)])
198
+ async def nudge_dismiss(nid: str, next: str = Form("/ui")):
199
+ async with session() as db:
200
+ nudge = await nudges.clear(db, nid, via="ui")
201
+ if nudge is None:
202
+ return redirect(next, "No such request for attention.")
203
+ return redirect(next, f"Cleared: {nudge.agent.name} is no longer shown as waiting on you.")
204
+
205
+
206
+ # ---- live dashboard -----------------------------------------------------------------------------
207
+ def _render(name: str, **ctx: Any) -> str:
208
+ """Render a partial outside any HTTP request, for delivery over the websocket. Partials used
209
+ this way must not touch `request` or `flash`."""
210
+ return templates.get_template(name).render(now=now(), **ctx)
211
+
212
+
213
+ async def _live_payload(db, event: dict[str, Any]) -> dict[str, Any] | None:
214
+ """Turn a fact from the event hub into something a dashboard can apply. Markup is built here,
215
+ not in the approval path, so the two stay independent and Jinja stays in the web layer."""
216
+ kind = event["type"]
217
+ if kind == "resync":
218
+ return {**event, **await _live_state(db)}
219
+ counts = await _counts(db)
220
+ if kind == "request.created":
221
+ req = await db.get(AccessRequest, event["id"])
222
+ if req is None or req.status != "pending":
223
+ return None # decided or expired between the publish and here; its own event follows
224
+ return {**event, "counts": counts, "html": _render("_request_card.html", req=req)}
225
+ if kind == "nudge.created":
226
+ nudge = await db.get(Nudge, event["id"])
227
+ if nudge is None or not nudge.open:
228
+ return None
229
+ return {**event, "counts": counts, "html": _render("_nudge_card.html", n=nudge)}
230
+ return {**event, "counts": counts}
231
+
232
+
233
+ async def _live_state(db) -> dict[str, Any]:
234
+ return {"counts": await _counts(db), "html": _render("_live.html", **await _waiting(db))}
235
+
236
+
237
+ @router.get("/ui/live", response_class=JSONResponse, dependencies=[Depends(auth.require_admin)])
238
+ async def live_state(request: Request):
239
+ """The whole live region, for a dashboard that has just reconnected or fallen behind."""
240
+ async with session() as db:
241
+ return JSONResponse({"type": "resync", **await _live_state(db)})
242
+
243
+
244
+ @ws_router.websocket("/ui/events")
245
+ async def live_events(ws: WebSocket) -> None:
246
+ """One socket per open dashboard: new requests appear, decided ones resolve, including when the
247
+ decision came from a phone. Read-only - nothing a browser sends here changes anything."""
248
+ if not websocket_same_origin(ws.headers.get("origin"), str(ws.url)):
249
+ log.warning("rejected dashboard websocket from origin %r", ws.headers.get("origin"))
250
+ return await ws.close(code=1008)
251
+ if not await auth.authenticated(ws):
252
+ return await ws.close(code=1008)
253
+ await ws.accept()
254
+ async with events.subscribe() as subscriber:
255
+ async with session() as db:
256
+ await ws.send_json({"type": "hello", "counts": await _counts(db)})
257
+ closed = asyncio.create_task(_until_closed(ws))
258
+ pending: asyncio.Task | None = None
259
+ try:
260
+ while not closed.done():
261
+ pending = asyncio.ensure_future(subscriber.get())
262
+ done, _ = await asyncio.wait({closed, pending}, timeout=HEARTBEAT_S,
263
+ return_when=asyncio.FIRST_COMPLETED)
264
+ if closed in done:
265
+ break
266
+ if not await auth.authenticated(ws):
267
+ await ws.close(code=1008)
268
+ break
269
+ if pending not in done:
270
+ pending.cancel()
271
+ await ws.send_json({"type": "ping"}) # keeps idle proxies from closing us
272
+ continue
273
+ async with session() as db:
274
+ payload = await _live_payload(db, pending.result())
275
+ if payload is not None:
276
+ await ws.send_json(payload)
277
+ except (WebSocketDisconnect, RuntimeError, ConnectionError):
278
+ pass
279
+ finally:
280
+ closed.cancel()
281
+ if pending is not None:
282
+ pending.cancel()
283
+ with contextlib.suppress(Exception):
284
+ await ws.close()
285
+
286
+
287
+ HEARTBEAT_S = 25
288
+
289
+
290
+ async def _until_closed(ws: WebSocket) -> None:
291
+ """Completes when the browser goes away. A dashboard sends nothing, so anything it does send
292
+ is discarded."""
293
+ with contextlib.suppress(Exception):
294
+ while True:
295
+ if (await ws.receive())["type"] == "websocket.disconnect":
296
+ return
297
+
298
+
299
+ @router.get("/ui/requests", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
300
+ async def requests_list(request: Request):
301
+ async with session() as db:
302
+ rows = (await db.execute(select(AccessRequest).order_by(desc(AccessRequest.created_at)).limit(200))).scalars().all()
303
+ return render(request, "requests.html", rows=rows)
304
+
305
+
306
+ @router.get("/ui/requests/{rid}", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
307
+ async def request_detail(request: Request, rid: str):
308
+ async with session() as db:
309
+ req = await db.get(AccessRequest, rid)
310
+ if req is None:
311
+ return redirect("/ui", "No such request.")
312
+ grants = (await db.execute(select(Grant).where(Grant.request_id == rid))).scalars().all()
313
+ grant_refs = {f"grant:{g.id}" for g in grants}
314
+ rules = (await db.execute(select(Policy).where(Policy.request_id == rid))).scalars().all()
315
+ refs = grant_refs | {f"policy:{p.id}" for p in rules}
316
+ # Follow recorded authorization references, without inferring downstream effects.
317
+ # The ts index bounds the scan; the text match only narrows it, the filter below decides.
318
+ text = cast(AuditEvent.detail, String)
319
+ events = (await db.execute(select(AuditEvent).where(
320
+ AuditEvent.ts >= req.created_at,
321
+ or_(*(text.contains(ref, autoescape=True) for ref in sorted({rid, *refs}))),
322
+ ).order_by(AuditEvent.id))).scalars().all()
323
+ timeline = [e for e in events if (e.detail or {}).get("request_id") == rid
324
+ or rid in ((e.detail or {}).get("request_ids") or [])
325
+ or refs.intersection(((e.detail or {}).get("matched") or {}).values())]
326
+ return render(request, "request_detail.html", req=req, grants=grants, timeline=timeline, rules=rules)
327
+
328
+
329
+ @router.post("/ui/requests/{rid}/decide", dependencies=[Depends(auth.require_admin)])
330
+ async def request_decide(rid: str, scope: str = Form(...), deny_reason: str = Form(""), ttl_minutes: int = Form(60),
331
+ next: str = Form("/ui"), extent: str | None = Form(None)):
332
+ started = now()
333
+ async with session() as db:
334
+ try:
335
+ req = await approvals.decide(db, rid, scope, via="ui", deny_reason=deny_reason,
336
+ ttl_s=ttl_minutes * 60, extent=extent)
337
+ except (LookupError, ValueError) as e:
338
+ return redirect(next, str(e))
339
+ if req.status == "expired":
340
+ return redirect(next, f"Request {req.id} expired before it was decided; nothing was granted.")
341
+ if req.decided_at is None or req.decided_at < started:
342
+ return redirect(next, f"Request {req.id} was already {req.status} ({approvals.decision_label(req)}) "
343
+ f"via {req.decided_via}; this choice was not applied.")
344
+ return redirect(next, f"Request {req.id}: {req.status} ({approvals.decision_label(req)}).")
345
+
346
+
347
+ # ---- decision links (signed, no login) ----------------------------------------------------------
348
+ @router.get("/d/{token}", response_class=HTMLResponse)
349
+ async def decision_page(request: Request, token: str):
350
+ parsed = approvals.parse_decision_token(token)
351
+ if not parsed:
352
+ return render(request, "decision.html", error="This link is invalid or has expired.")
353
+ rid, scope = parsed
354
+ async with session() as db:
355
+ req = await db.get(AccessRequest, rid)
356
+ if req is None:
357
+ return render(request, "decision.html", error="No such request.")
358
+ return render(request, "decision.html", req=req, scope=scope, token=token)
359
+
360
+
361
+ @router.post("/d/{token}")
362
+ async def decision_submit(request: Request, token: str, deny_reason: str = Form("")):
363
+ parsed = approvals.parse_decision_token(token)
364
+ if not parsed:
365
+ return render(request, "decision.html", error="This link is invalid or has expired.")
366
+ rid, scope = parsed
367
+ async with session() as db:
368
+ try:
369
+ req = await approvals.decide(db, rid, scope, via="link", deny_reason=deny_reason)
370
+ except (LookupError, ValueError) as e:
371
+ return render(request, "decision.html", error=str(e))
372
+ return render(request, "decision.html", req=req, scope=scope, done=True)
373
+
374
+
375
+ # ---- grants & policies --------------------------------------------------------------------------
376
+ @router.get("/ui/grants", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
377
+ async def grants_list(request: Request, show: str = ""):
378
+ history = show == "all"
379
+ async with session() as db:
380
+ q = select(Grant).order_by(desc(Grant.created_at))
381
+ live = q.where(Grant.revoked_at.is_(None), or_(Grant.expires_at.is_(None), Grant.expires_at > now()))
382
+ rows = (await db.execute(q.limit(500) if history else live)).scalars().all()
383
+ agents = {a.id: a for a in (await db.execute(select(Agent))).scalars().all()}
384
+ ids = {g.request_id for g in rows if g.request_id}
385
+ requests = {r.id: r for r in (await db.execute(select(AccessRequest).where(
386
+ AccessRequest.id.in_(ids)))).scalars().all()} if ids else {}
387
+ groups = access_view.grant_groups(rows, agents, requests, now(), include_inactive=history)
388
+ return render(request, "grants.html", groups=groups, history=history,
389
+ active_total=sum(a["active"] for a in groups))
390
+
391
+
392
+ @router.post("/ui/grants/{gid}/revoke", dependencies=[Depends(auth.require_admin)])
393
+ async def grant_revoke(gid: str, next: str = Form("/ui/grants")):
394
+ async with session() as db:
395
+ await approvals.revoke_grant(db, gid)
396
+ return redirect(next, "Grant revoked for future calls. Other grants, rules, or open defaults may still allow access. "
397
+ "Running operations are not stopped.")
398
+
399
+
400
+ @router.post("/ui/requests/{rid}/revoke", dependencies=[Depends(auth.require_admin)])
401
+ async def request_revoke(rid: str, next: str = Form("/ui")):
402
+ async with session() as db:
403
+ count = await approvals.revoke_request_grants(db, rid)
404
+ return redirect(next, f"Revoked {count} grants from this approval. Other permissions may still allow access. "
405
+ "Running operations are not stopped.")
406
+
407
+
408
+ @router.post("/ui/grants/revoke-all", dependencies=[Depends(auth.require_admin)])
409
+ async def grants_revoke_all(agent_id: str = Form(""), next: str = Form("/ui/grants")):
410
+ async with session() as db:
411
+ q = select(Grant).where(Grant.revoked_at.is_(None))
412
+ if agent_id:
413
+ q = q.where(Grant.agent_id == agent_id)
414
+ n = 0
415
+ for g in (await db.execute(q)).scalars().all():
416
+ await approvals.revoke_grant(db, g.id)
417
+ n += 1
418
+ return redirect(next, f"Revoked {n} grants. Standing rules and open defaults remain. Running operations continue.")
419
+
420
+
421
+ @router.get("/ui/policies", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
422
+ async def policies_list(request: Request):
423
+ return await _policies_page(request)
424
+
425
+
426
+ async def _policies_page(request: Request, *, draft=None, preview=None, error=None):
427
+ async with session() as db:
428
+ rows = (await db.execute(select(Policy).order_by(Policy.effect, Policy.pattern))).scalars().all()
429
+ agents = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
430
+ return render(request, "policies.html", rows=rows, agents=agents, agent_map={a.id: a for a in agents},
431
+ draft=draft or {}, preview=preview, error=error)
432
+
433
+
434
+ @router.post("/ui/policies", dependencies=[Depends(auth.require_admin)])
435
+ async def policy_create(request: Request, pattern: str = Form(...), effect: str = Form(...),
436
+ agent_id: str = Form(""), note: str = Form(""), action: str = Form("save")):
437
+ draft = {"pattern": pattern, "effect": effect, "agent_id": agent_id, "note": note}
438
+ try:
439
+ pattern = validate(pattern)
440
+ except ValueError as e:
441
+ return await _policies_page(request, draft=draft, error=f"Invalid pattern: {e}")
442
+ if effect not in ("allow", "deny"):
443
+ return await _policies_page(request, draft=draft, error="Effect must be allow or deny.")
444
+ if action not in ("save", "preview"):
445
+ return await _policies_page(request, draft=draft, error="Unknown rule action.")
446
+ async with session() as db:
447
+ if agent_id and await db.get(Agent, agent_id) is None:
448
+ return await _policies_page(request, draft=draft, error="No such agent.")
449
+ candidate = Policy(id="preview" if action == "preview" else None, pattern=pattern, effect=effect,
450
+ agent_id=agent_id or None, note=note, created_by="ui")
451
+ if action == "preview":
452
+ report = await policy_preview.preview(db, candidate)
453
+ return await _policies_page(request, draft=draft, preview=report)
454
+ db.add(candidate)
455
+ await db.commit()
456
+ return redirect("/ui/policies", f"Rule added: {effect} {pattern}")
457
+
458
+
459
+ @router.post("/ui/policies/{pid}/delete", dependencies=[Depends(auth.require_admin)])
460
+ async def policy_delete(pid: str):
461
+ async with session() as db:
462
+ p = await db.get(Policy, pid)
463
+ if p:
464
+ await db.delete(p)
465
+ await db.commit()
466
+ return redirect("/ui/policies", "Rule deleted.")
467
+
468
+
469
+ # ---- agents -------------------------------------------------------------------------------------
470
+ # ---- access page: every tool (or model) of one resource, and one agent's standing on each ---------
471
+ @router.get("/ui/access", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
472
+ async def access_page(request: Request, resource: str = "", agent: str = "", scope: str | None = None,
473
+ refresh: str = ""):
474
+ async with session() as db:
475
+ resources = (await db.execute(select(Resource).where(Resource.kind.in_(("mcp", "llm")))
476
+ .order_by(Resource.kind, Resource.name))).scalars().all()
477
+ agents = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
478
+ res = next((r for r in resources if r.id == resource), resources[0] if resources else None)
479
+ ag = next((a for a in agents if a.id == agent), None)
480
+ ctx: dict[str, Any] = {"resources": resources, "agents": agents, "res": res, "ag": ag, "groups": {},
481
+ "error": None, "scope": "", "drift": None}
482
+ if res is None:
483
+ return render(request, "access.html", **ctx)
484
+ try:
485
+ rows, scope_text = await _access_rows(res, ag.id if ag else None, scope, force=bool(refresh))
486
+ except ValueError as e:
487
+ rows, scope_text, ctx["error"] = [], scope or "", str(e)
488
+ except Exception as e: # noqa: BLE001
489
+ rows, scope_text, ctx["error"] = [], scope or "", f"Could not list upstream tools: {mcp_proxy.describe(e)}"
490
+ for row in rows:
491
+ ctx["groups"].setdefault(row.group, []).append(row)
492
+ order = {g: i for i, g in enumerate((*tool_access.GROUPS, "models"))}
493
+ ctx["groups"] = dict(sorted(ctx["groups"].items(), key=lambda kv: order.get(kv[0], 99)))
494
+ ctx.update(scope=scope_text, drift=res.config.get("tools_drift"), arg_keys=res.config.get("arg_keys") or [])
495
+ return render(request, "access.html", **ctx)
496
+
497
+
498
+ async def _access_rows(res: Resource, agent_id: str | None, scope: str | None, *, force: bool = False):
499
+ """The page's rows, fresh from the upstream. `scope` None means the one last saved for this resource."""
500
+ async with session() as db:
501
+ policies = (await db.execute(select(Policy).where(
502
+ (Policy.agent_id == agent_id) | Policy.agent_id.is_(None)))).scalars().all()
503
+ if res.kind == "llm":
504
+ return tool_access.llm_rows(res, llm.model_paths(res), policies, agent_id), ""
505
+ scope_text = res.config.get("access_scope", "") if scope is None else scope
506
+ parsed = tool_access.parse_scope(scope_text, res.config.get("arg_keys") or [])
507
+ tools = await mcp_proxy.list_tools(res.name, res.config, force=force)
508
+ return tool_access.mcp_rows(res, tools, policies, agent_id, parsed), scope_text
509
+
510
+
511
+ @router.post("/ui/access", dependencies=[Depends(auth.require_admin)])
512
+ async def access_save(request: Request):
513
+ form = await request.form()
514
+ rid, aid, scope = str(form.get("resource", "")), str(form.get("agent", "")), str(form.get("scope", ""))
515
+ back = f"/ui/access?resource={rid}&agent={aid}"
516
+ async with session() as db:
517
+ res = await db.get(Resource, rid)
518
+ ag = await db.get(Agent, aid) if aid else None
519
+ if res is None or (aid and ag is None):
520
+ return redirect("/ui/access", "No such resource or agent.")
521
+ try:
522
+ rows, _ = await _access_rows(res, ag.id if ag else None, scope)
523
+ except ValueError as e:
524
+ return redirect(back, f"Not saved: {e}")
525
+ except Exception as e: # noqa: BLE001
526
+ return redirect(back, f"Not saved; could not list upstream tools: {mcp_proxy.describe(e)}")
527
+ wanted = {k[2:]: str(v) for k, v in form.items() if k.startswith("s:")}
528
+ async with session() as db:
529
+ policies = (await db.execute(select(Policy).where(
530
+ (Policy.agent_id == (ag.id if ag else None)) if ag else Policy.agent_id.is_(None)))).scalars().all()
531
+ change = tool_access.plan(rows, wanted, policies, ag.id if ag else None)
532
+ for p in change.delete:
533
+ await db.delete(p)
534
+ for pattern, effect in change.add:
535
+ db.add(Policy(agent_id=ag.id if ag else None, pattern=pattern, effect=effect, created_by="ui",
536
+ note="access page"))
537
+ fresh = await db.get(Resource, res.id)
538
+ if fresh.kind == "mcp" and fresh.config.get("access_scope", "") != scope:
539
+ fresh.config = {**fresh.config, "access_scope": scope}
540
+ await db.commit()
541
+ if change:
542
+ await audit.record(db, tool="access.edit", decision="edit", agent=ag,
543
+ detail={"resource": res.name, "every_agent": ag is None,
544
+ "added": [f"{e} {p}" for p, e in change.add],
545
+ "removed": [f"{p.effect} {p.pattern}" for p in change.delete]})
546
+ who = ag.name if ag else "every agent"
547
+ if not change:
548
+ return redirect(back, "No changes.")
549
+ return redirect(back, f"{res.name} for {who}: {len(change.add)} rule(s) added, {len(change.delete)} removed.")
550
+
551
+
552
+ @router.post("/ui/resources/{rid}/tools/accept", dependencies=[Depends(auth.require_admin)])
553
+ async def resource_tools_accept(rid: str, agent: str = Form("")):
554
+ """The owner has looked at the drift: today's tool list becomes the baseline."""
555
+ async with session() as db:
556
+ res = await db.get(Resource, rid)
557
+ if res is None or res.kind != "mcp":
558
+ return redirect("/ui/access", "No such MCP resource.")
559
+ try:
560
+ tools = await mcp_proxy.list_tools(res.name, res.config, force=True)
561
+ except Exception as e: # noqa: BLE001
562
+ return redirect(f"/ui/access?resource={rid}&agent={agent}", f"Could not list tools: {mcp_proxy.describe(e)}")
563
+ res.config = {**res.config, **tool_access.baseline(tools)}
564
+ await db.commit()
565
+ await audit.record(db, tool="tools.accept", decision="accept", detail={"resource": res.name,
566
+ "tools": len(tools)})
567
+ return redirect(f"/ui/access?resource={rid}&agent={agent}", f"{res.name}: {len(tools)} tools are the new baseline.")
568
+
569
+
570
+ @router.get("/ui/agents", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
571
+ async def agents_list(request: Request):
572
+ async with session() as db:
573
+ rows = (await db.execute(select(Agent).order_by(Agent.name))).scalars().all()
574
+ gateways = (await db.execute(select(Resource).where(Resource.kind == "llm").order_by(Resource.name))
575
+ ).scalars().all()
576
+ new_token = request.cookies.get("kw_new_token")
577
+ resp = render(request, "agents.html", rows=rows, new_token=new_token, mcp_url=_public_url() + "/mcp",
578
+ gateways=gateways, public_url=_public_url())
579
+ if new_token:
580
+ resp.delete_cookie("kw_new_token")
581
+ return resp
582
+
583
+
584
+ @router.post("/ui/agents", dependencies=[Depends(auth.require_admin)])
585
+ async def agent_create(name: str = Form(...), note: str = Form("")):
586
+ name = name.strip()
587
+ if not name:
588
+ return redirect("/ui/agents", "Name is required.")
589
+ token = "kw_" + secrets.token_urlsafe(32)
590
+ async with session() as db:
591
+ if (await db.execute(select(Agent).where(Agent.name == name))).scalar_one_or_none():
592
+ return redirect("/ui/agents", "An agent with that name exists.")
593
+ db.add(Agent(name=name, token_hash=hash_token(token), token_prefix=token[:8], note=note))
594
+ await db.commit()
595
+ resp = redirect("/ui/agents", f"Agent {name} created. Copy the token now; it is not shown again.")
596
+ resp.set_cookie("kw_new_token", token, max_age=120, httponly=True, samesite="lax")
597
+ return resp
598
+
599
+
600
+ @router.post("/ui/agents/{aid}/{action}", dependencies=[Depends(auth.require_admin)])
601
+ async def agent_action(aid: str, action: str, next: str = Form("/ui/agents")):
602
+ async with session() as db:
603
+ a = await db.get(Agent, aid)
604
+ if a is None:
605
+ return redirect("/ui/agents", "No such agent.")
606
+ token = None
607
+ if action == "disable":
608
+ a.disabled_at = now()
609
+ elif action == "enable":
610
+ a.disabled_at = None
611
+ elif action == "rotate":
612
+ token = "kw_" + secrets.token_urlsafe(32)
613
+ a.token_hash, a.token_prefix = hash_token(token), token[:8]
614
+ elif action == "delete":
615
+ await db.delete(a)
616
+ else:
617
+ return redirect("/ui/agents", "Unknown action.")
618
+ if action in ("disable", "delete"):
619
+ # The agent's LLM keys are its authority to spend; they go before the row does.
620
+ await llm.revoke_for_agent(db, a.id, via="ui")
621
+ await db.commit()
622
+ await audit.record(db, tool=f"agent.{action}", decision=action, agent=a,
623
+ detail={"via": "ui"})
624
+ message = f"Agent {a.name}: {action}."
625
+ if action == "disable":
626
+ message += " New operations are blocked. Running operations are not stopped."
627
+ resp = redirect(next, message)
628
+ if token:
629
+ resp.set_cookie("kw_new_token", token, max_age=120, httponly=True, samesite="lax")
630
+ return resp
631
+
632
+
633
+ # ---- resources ----------------------------------------------------------------------------------
634
+ @router.get("/ui/resources", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
635
+ async def resources_list(request: Request, sort: str = "type", group: str = "none"):
636
+ async with session() as db:
637
+ data = await inventory.snapshot(db)
638
+ return render(request, "resources.html", **inventory.resource_inventory(data, sort, group))
639
+
640
+
641
+ @router.get("/ui/resources/new", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
642
+ async def resource_new(request: Request, kind: str = "", adapter: str = "", integration: str = "", q: str = ""):
643
+ preset = integrations.PRESET_BY_ID.get(integration or adapter)
644
+ if integration and preset is None:
645
+ return redirect("/ui/resources/new", "Choose an integration from the list.")
646
+ if not kind and not preset:
647
+ terms = q.lower().split()
648
+ groups = [[{"item": item, "matches": all(term in item.search_text for term in terms)} for item in group]
649
+ for group in (integrations.PRESETS, integrations.GENERICS)]
650
+ return render(request, "integration_picker.html", presets=groups[0], generics=groups[1], query=q,
651
+ matches=sum(entry["matches"] for group in groups for entry in group))
652
+ if preset:
653
+ kind, adapter = preset.kind, preset.adapter
654
+ if kind not in ("ssh", "mcp", "llm"):
655
+ return redirect("/ui/resources/new", "Choose a connection type from the list.")
656
+ return render(request, "resource_form.html", res=None, kind=kind, keys_dir=settings.keys_dir,
657
+ preset=preset,
658
+ oauth_callback=settings.public_url.rstrip("/") + mcp_oauth.CALLBACK_PATH,
659
+ inventory_bundles=inventory_mcp.DEFAULT_BUNDLES,
660
+ adapter=adapter if kind == "mcp" and adapter in ("omada", "keywright") else "")
661
+
662
+
663
+ @router.get("/ui/resources/{rid}", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
664
+ async def resource_edit(request: Request, rid: str):
665
+ async with session() as db:
666
+ res = await db.get(Resource, rid)
667
+ link = await db.get(inventory.ResourceLink, rid)
668
+ asset = await db.get(inventory.Asset, link.asset_id) if link else None
669
+ if res is None:
670
+ return redirect("/ui/resources", "No such resource.")
671
+ if res.config.get("adapter") == "knowledge":
672
+ return redirect("/ui/knowledge?collection=" + res.id)
673
+ return render(request, "resource_form.html", res=res, kind=res.kind, keys_dir=settings.keys_dir,
674
+ preset=integrations.for_resource(res),
675
+ inventory_asset=asset,
676
+ oauth_callback=settings.public_url.rstrip("/") + mcp_oauth.CALLBACK_PATH,
677
+ oauth_status=await mcp_oauth.status(res.config) if res.config.get("auth_type") == "oauth" else None,
678
+ adapter=res.config.get("adapter", ""),
679
+ key=key_info(res) if res.kind == "ssh" else None,
680
+ leases=await llm_leases(res) if res.kind == "llm" else None)
681
+
682
+
683
+ async def llm_leases(res: Resource) -> dict[str, Any]:
684
+ """Active keys on a gateway with what they have spent, and the exposure they add up to."""
685
+ async with session() as db:
686
+ keys = await llm.active_keys(db, res)
687
+ agents = {a.id: a.name for a in (await db.execute(select(Agent))).scalars().all()}
688
+ spend = {k.id: await llm.key_spend(res.config, k.key) for k in keys}
689
+ return {"leases": [{"row": k, "agent": agents.get(k.agent_id, "?"), "spend": spend[k.id],
690
+ "remaining": access_view.remaining(k.expires_at, now())} for k in keys],
691
+ "exposure": llm.exposure(keys, spend), "health": res.config.get("health") or {},
692
+ "enforced": llm.enforces_budgets(res.config.get("health"))}
693
+
694
+
695
+ @router.post("/ui/resources", dependencies=[Depends(auth.require_admin)])
696
+ async def resource_save(
697
+ request: Request,
698
+ rid: str = Form(""), kind: str = Form(...), name: str = Form(...), default_mode: str = Form("approval"),
699
+ description: str = Form(""), host: str = Form(""), port: int = Form(22), user: str = Form("root"),
700
+ key_path: str = Form(""), private_key: str = Form(""), url: str = Form(""), headers: str = Form(""),
701
+ forget_host_key: str = Form(""), arg_keys: str = Form(""), audit_handle: str = Form(""),
702
+ bundles_text: str = Form(""), hazard_tools_text: str = Form(""),
703
+ result_inspection_mode: str = Form(""),
704
+ master_key: str = Form(""), models_text: str = Form(""), max_budget: str = Form(""), rpm_limit: str = Form(""),
705
+ tpm_limit: str = Form(""), max_parallel_requests: str = Form(""), agent_max_budget: str = Form(""),
706
+ agent_budget_duration: str = Form("1d"),
707
+ adapter: str = Form(""), omada_id: str = Form(""), client_id: str = Form(""),
708
+ client_secret: str = Form(""), verify_tls: str = Form("1"), ca_bundle: str = Form(""),
709
+ auth_type: str = Form("headers"), oauth_client_id: str = Form(""), oauth_client_secret: str = Form(""),
710
+ oauth_scope: str = Form(""), oauth_auth_method: str = Form("none"), oauth_origins: str = Form(""),
711
+ integration: str = Form(""), setup_action: str = Form(""),
712
+ ):
713
+ preset = integrations.PRESET_BY_ID.get(integration)
714
+ if integration and preset is None:
715
+ return redirect("/ui/resources/new", "Choose an integration from the list.")
716
+ if preset:
717
+ kind, adapter = preset.kind, preset.adapter
718
+ if preset.url:
719
+ url, auth_type = preset.url, preset.auth_type
720
+ if setup_action and (setup_action != "connect" or not preset or preset.auth_type != "oauth"):
721
+ return redirect("/ui/resources/new", "Invalid integration setup action.")
722
+ name = name.strip()
723
+ if not name or not all(c.isalnum() or c in "_.-" for c in name):
724
+ return redirect("/ui/resources", "Name must be alphanumeric with _ . - only.")
725
+ if kind not in ("ssh", "mcp", "llm") or default_mode not in ("open", "approval", "locked"):
726
+ return redirect("/ui/resources", "Invalid kind or mode.")
727
+ if kind == "llm":
728
+ try:
729
+ limits = {"max_budget": _money(max_budget, required=True), "rpm_limit": _count(rpm_limit),
730
+ "tpm_limit": _count(tpm_limit), "max_parallel_requests": _count(max_parallel_requests)}
731
+ agent_budget = {"max_budget": _money(agent_max_budget, required=True),
732
+ "budget_duration": agent_budget_duration}
733
+ except ValueError as e:
734
+ return redirect(f"/ui/resources/{rid}" if rid else "/ui/resources/new?kind=llm", f"Not saved: {e}")
735
+ if agent_budget_duration not in llm.BUDGET_DURATIONS:
736
+ return redirect("/ui/resources", "Agent budget window must be one of " + ", ".join(llm.BUDGET_DURATIONS))
737
+ async with session() as db:
738
+ res = await db.get(Resource, rid) if rid else None
739
+ if res is None:
740
+ if (await db.execute(select(Resource).where(Resource.name == name))).scalar_one_or_none():
741
+ return redirect("/ui/resources", "A resource with that name exists.")
742
+ res = Resource(kind=kind, name=name, config={})
743
+ db.add(res)
744
+ cfg = dict(res.config or {})
745
+ if cfg.get("integration") and cfg["integration"] != integration:
746
+ return redirect(f"/ui/resources/{res.id}", "Keep this resource's integration when saving its settings.")
747
+ if preset:
748
+ cfg["integration"] = preset.id
749
+ if res.id and (kind != res.kind or adapter != cfg.get("adapter", "")):
750
+ return redirect("/ui/resources", "Create a new resource to change its connection type.")
751
+ if adapter not in ("", "omada", "keywright") or (adapter and kind != "mcp"):
752
+ return redirect("/ui/resources", "Invalid connection type.")
753
+ if kind == "ssh":
754
+ if private_key.strip():
755
+ settings.ensure_dirs()
756
+ p = settings.keys_dir / name
757
+ p.write_text(private_key.strip() + "\n")
758
+ p.chmod(0o600)
759
+ key_path = str(p)
760
+ cfg.update({"host": host.strip(), "port": int(port or 22), "user": user.strip() or "root",
761
+ "key_path": key_path.strip() or cfg.get("key_path", "")})
762
+ if forget_host_key:
763
+ cfg.pop("host_key", None)
764
+ elif kind == "llm":
765
+ models = [m.strip() for m in models_text.replace("\n", ",").split(",") if m.strip()]
766
+ cfg.update({"url": url.strip().rstrip("/"),
767
+ "master_key": cfg.get("master_key", "") if master_key.strip() in ("", "***")
768
+ else master_key.strip(),
769
+ "models": models, "limits": limits, "agent_budget": agent_budget})
770
+ else:
771
+ inspection_mode = result_inspection_mode or cfg.get("result_inspection", "off")
772
+ if inspection_mode not in result_inspection.MODES:
773
+ return redirect("/ui/resources", "Invalid result inspection mode.")
774
+ hdrs: dict[str, str] = {}
775
+ for line in headers.splitlines():
776
+ if ":" in line:
777
+ k, v = line.split(":", 1)
778
+ hdrs[k.strip()] = v.strip()
779
+ existing = cfg.get("headers") or {}
780
+ # keep a stored secret if the form shows the placeholder
781
+ for k, v in hdrs.items():
782
+ if v == "***" and k in existing:
783
+ hdrs[k] = existing[k]
784
+ keys = [k.strip() for k in arg_keys.replace("\n", ",").split(",") if k.strip()]
785
+ if any(not all(c.isalnum() or c in "_.-" for c in k) for k in keys):
786
+ return redirect("/ui/resources", "Argument keys must be dotted names, e.g. service, data.entity_id.")
787
+ try:
788
+ parsed_bundles = bundles.parse(bundles_text, name)
789
+ except ValueError as e:
790
+ return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources", f"Bundles not saved: {e}")
791
+ try:
792
+ parsed_hazards = bundles.parse(hazard_tools_text, name)
793
+ except ValueError as e:
794
+ return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources", f"Hazard catalog not saved: {e}")
795
+ if any(sev not in SEVERITIES for sev in parsed_hazards):
796
+ return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources",
797
+ f"Hazard catalog lines must start with one of {', '.join(SEVERITIES)}.")
798
+ cfg.update({"url": url.strip(), "headers": hdrs, "arg_keys": keys, "audit_handle": audit_handle == "1",
799
+ "bundles": parsed_bundles, "hazard_tools": parsed_hazards,
800
+ "result_inspection": inspection_mode})
801
+ if not adapter:
802
+ if auth_type not in ("headers", "oauth"):
803
+ return redirect("/ui/resources", "Invalid MCP authentication method.")
804
+ cfg["auth_type"] = auth_type
805
+ if auth_type == "oauth":
806
+ cfg["headers"] = {k: v for k, v in hdrs.items() if k.lower() != "authorization"}
807
+ cfg["oauth"] = {"client_id": oauth_client_id.strip(), "scope": oauth_scope.strip(),
808
+ "token_endpoint_auth_method": oauth_auth_method,
809
+ "origins": [v.strip().rstrip("/") for v in
810
+ oauth_origins.replace("\n", ",").split(",") if v.strip()]}
811
+ try:
812
+ await mcp_oauth.configure(db, res, cfg, secret=oauth_client_secret.strip())
813
+ except mcp_oauth.ConnectionError as exc:
814
+ return redirect(f"/ui/resources/{res.id}" if res.id else "/ui/resources", str(exc))
815
+ if adapter == "keywright":
816
+ cfg.update({"adapter": "keywright"})
817
+ cfg.pop("url", None)
818
+ cfg.pop("headers", None)
819
+ if adapter == "omada":
820
+ if verify_tls not in ("0", "1"):
821
+ return redirect("/ui/resources", "Invalid TLS verification setting.")
822
+ cfg.update({"adapter": "omada", "url": url.strip().rstrip("/"),
823
+ "omada_id": omada_id.strip(), "client_id": client_id.strip(),
824
+ "client_secret": cfg.get("client_secret", "") if client_secret.strip() in ("", "***")
825
+ else client_secret.strip(), "verify_tls": verify_tls == "1",
826
+ "ca_bundle": ca_bundle.strip()})
827
+ try:
828
+ omada.validate_config(cfg)
829
+ except ValueError as e:
830
+ return redirect(f"/ui/resources/{res.id}" if res.id else
831
+ "/ui/resources/new?kind=mcp&adapter=omada", f"Not saved: {e}")
832
+ mcp_proxy.forget(name)
833
+ res.name, res.default_mode, res.description, res.config = name, default_mode, description, cfg
834
+ await db.commit()
835
+ rid = res.id
836
+ if setup_action == "connect":
837
+ return await mcp_oauth_ui.connect(request, rid)
838
+ return redirect(f"/ui/resources/{rid}", "Saved.")
839
+
840
+
841
+ def _money(text: str, *, required: bool = False) -> float | None:
842
+ text = text.strip().lstrip("$")
843
+ if not text:
844
+ if required:
845
+ raise ValueError("a spend ceiling in USD is required; it is the whole point")
846
+ return None
847
+ try:
848
+ value = float(text)
849
+ except ValueError:
850
+ raise ValueError(f"{text!r} is not an amount in USD") from None
851
+ if value <= 0 or value > 100000:
852
+ raise ValueError("a spend ceiling must be more than $0 and at most $100000")
853
+ return round(value, 4)
854
+
855
+
856
+ def _count(text: str) -> int | None:
857
+ text = text.strip()
858
+ if not text:
859
+ return None
860
+ if not text.isdigit() or int(text) <= 0:
861
+ raise ValueError(f"{text!r} is not a positive whole number")
862
+ return int(text)
863
+
864
+
865
+ @router.post("/ui/resources/{rid}/suggest-bundles", dependencies=[Depends(auth.require_admin)])
866
+ async def resource_suggest_bundles(rid: str):
867
+ async with session() as db:
868
+ res = await db.get(Resource, rid)
869
+ if res is None or res.kind != "mcp":
870
+ return redirect("/ui/resources", "No such MCP resource.")
871
+ try:
872
+ tools = await mcp_proxy.list_tools(res.name, res.config, force=True)
873
+ except Exception as e: # noqa: BLE001
874
+ return redirect(f"/ui/resources/{rid}", f"Could not list upstream tools: {type(e).__name__}: {e}")
875
+ suggested = bundles.suggest([t.name for t in tools])
876
+ existing = dict(res.config.get("bundles") or {})
877
+ added = {n: es for n, es in suggested.items() if n not in existing}
878
+ res.config = {**res.config, "bundles": {**existing, **added}}
879
+ await db.commit()
880
+ return redirect(f"/ui/resources/{rid}", f"Suggested {len(added)} bundle(s) from {len(tools)} tools by verb; "
881
+ "review and edit them, they are a starting point, not a policy.")
882
+
883
+
884
+ @router.post("/ui/resources/{rid}/probe", dependencies=[Depends(auth.require_admin)])
885
+ async def resource_probe(rid: str):
886
+ async with session() as db:
887
+ res = await db.get(Resource, rid)
888
+ if res is None:
889
+ return redirect("/ui/resources", "No such resource.")
890
+ if res.kind == "ssh":
891
+ ok, msg, learned = await ssh.probe(res.config)
892
+ if learned:
893
+ res.config = {**res.config, "host_key": learned}
894
+ await db.commit()
895
+ msg += " Host key pinned."
896
+ elif res.kind == "llm":
897
+ ok, msg, health = await llm.probe(res.config)
898
+ models = health.pop("models", None)
899
+ cfg = {**res.config, "health": health}
900
+ if models is not None:
901
+ cfg["models"] = models
902
+ msg += " Model list refreshed."
903
+ res.config = cfg
904
+ await db.commit()
905
+ else:
906
+ ok, msg = await mcp_proxy.probe(res.config)
907
+ return redirect(f"/ui/resources/{rid}", ("OK: " if ok else "FAILED: ") + msg)
908
+
909
+
910
+ @router.post("/ui/resources/{rid}/delete", dependencies=[Depends(auth.require_admin)])
911
+ async def resource_delete(rid: str):
912
+ async with session() as db:
913
+ res = await db.get(Resource, rid)
914
+ removed = False
915
+ if res:
916
+ kp = (res.config or {}).get("key_path") or ""
917
+ if res.kind == "llm":
918
+ await llm.revoke_for_resource(db, res, via="ui")
919
+ if res.config.get("oauth_id"):
920
+ await mcp_oauth.configure(db, res, {"auth_type": "headers"})
921
+ if res.kind == "mcp":
922
+ mcp_proxy.forget(res.name)
923
+ await db.delete(res)
924
+ await db.commit()
925
+ # A key Keywright keeps for this resource goes with it, or the name can never be keyed
926
+ # again. Keys outside keys_dir, or still used by another resource, are the admin's.
927
+ others = (await db.execute(select(Resource))).scalars().all()
928
+ if (kp and Path(kp).resolve().parent == settings.keys_dir.resolve()
929
+ and not any((o.config or {}).get("key_path") == kp for o in others)):
930
+ for f in (Path(kp), Path(kp + ".pub")):
931
+ removed = removed or f.exists()
932
+ f.unlink(missing_ok=True)
933
+ return redirect("/ui/resources", "Resource deleted, with its key on this Keywright." if removed
934
+ else "Resource deleted.")
935
+
936
+
937
+ # ---- SSH key generation & host enrolment --------------------------------------------------------
938
+ def _enroll_serializer() -> URLSafeTimedSerializer:
939
+ return URLSafeTimedSerializer(settings.secret_key(), salt="enroll")
940
+
941
+
942
+ def key_info(res: Resource) -> dict[str, Any]:
943
+ """What the resource form shows about the key: exists? public line? enrol one-liner."""
944
+ kp = (res.config or {}).get("key_path") or ""
945
+ info: dict[str, Any] = {"key_path": kp, "exists": bool(kp) and os.path.exists(kp), "pub": ""}
946
+ if info["exists"]:
947
+ pub = kp + ".pub"
948
+ if os.path.exists(pub):
949
+ info["pub"] = Path(pub).read_text().strip()
950
+ if info["pub"]:
951
+ tok = _enroll_serializer().dumps({"r": res.id})
952
+ info["enroll_cmd"] = f"curl -fsSL {shlex.quote(_public_url() + '/enroll/' + tok)} | sh"
953
+ return info
954
+
955
+
956
+ @router.post("/ui/resources/{rid}/keygen", dependencies=[Depends(auth.require_admin)])
957
+ async def resource_keygen(rid: str):
958
+ async with session() as db:
959
+ res = await db.get(Resource, rid)
960
+ if res is None or res.kind != "ssh":
961
+ return redirect("/ui/resources", "No such SSH resource.")
962
+ try:
963
+ priv, _ = ssh.generate_key(res.name, settings.keys_dir)
964
+ except FileExistsError as e:
965
+ return redirect(f"/ui/resources/{rid}", f"Not overwriting: {e}. Delete it on the container first.")
966
+ res.config = {**res.config, "key_path": priv}
967
+ await db.commit()
968
+ return redirect(f"/ui/resources/{rid}", "Key generated on this Keywright. Now enrol it on the host.")
969
+
970
+
971
+ @router.post("/ui/resources/{rid}/enroll", dependencies=[Depends(auth.require_admin)])
972
+ async def resource_enroll(rid: str, password: str = Form(...), restrict: str = Form(""), login_user: str = Form(""),
973
+ escalate: str = Form(""), root_password: str = Form("")):
974
+ async with session() as db:
975
+ res = await db.get(Resource, rid)
976
+ if res is None or res.kind != "ssh":
977
+ return redirect("/ui/resources", "No such SSH resource.")
978
+ info = key_info(res)
979
+ if not info["pub"]:
980
+ return redirect(f"/ui/resources/{rid}", "Generate a key first.")
981
+ ok, msg, learned = await ssh.enroll_with_password(
982
+ res.config, password, info["pub"], restrict=bool(restrict), login_user=login_user,
983
+ escalate=escalate or None, root_password=root_password or None)
984
+ if learned:
985
+ res.config = {**res.config, "host_key": learned}
986
+ await db.commit()
987
+ await audit.record(db, tool="ssh.enroll", decision="allow" if ok else "error",
988
+ patterns=[f"ssh/{res.name}"], detail={"host": res.config.get("host"), "message": msg})
989
+ return redirect(f"/ui/resources/{rid}", ("Enrolled: " if ok else "Enrol failed: ") + msg)
990
+
991
+
992
+ @router.get("/enroll/{token}", response_class=PlainTextResponse)
993
+ async def enroll_script(token: str):
994
+ """Shell script that installs this resource's public key for the invoking user. The URL is a
995
+ signed 30-minute token, so it can be pasted into a terminal without logging into the UI."""
996
+ try:
997
+ data = _enroll_serializer().loads(token, max_age=1800)
998
+ except (BadSignature, SignatureExpired):
999
+ return PlainTextResponse("echo 'keywright: enrol link invalid or expired' >&2; exit 1\n", status_code=404)
1000
+ async with session() as db:
1001
+ res = await db.get(Resource, data.get("r", ""))
1002
+ src = (await get_setting(db, "general", {})).get("outbound_ip") or None
1003
+ if res is None or res.kind != "ssh":
1004
+ return PlainTextResponse("echo 'keywright: unknown resource' >&2; exit 1\n", status_code=404)
1005
+ info = key_info(res)
1006
+ if not info["pub"]:
1007
+ return PlainTextResponse("echo 'keywright: no key generated yet' >&2; exit 1\n", status_code=404)
1008
+ return ssh.enroll_script(info["pub"], src)
1009
+
1010
+
1011
+ # ---- settings -----------------------------------------------------------------------------------
1012
+ def _public_url() -> str:
1013
+ return approvals.public_url()
1014
+
1015
+
1016
+ @router.get("/ui/settings", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
1017
+ async def settings_page(request: Request):
1018
+ async with session() as db:
1019
+ general = await get_setting(db, "general", {"public_url": settings.public_url})
1020
+ notif = await get_setting(db, "notifications", {"driver": "log", "config": {}, "buttons": DEFAULT_BUTTONS})
1021
+ jev = await semantic.config(db)
1022
+ jev_tally = await semantic.shadow_tally(db)
1023
+ driver = registry.get(notif.get("driver") or "log", registry["log"])
1024
+ return render(request, "settings.html", general=general, notif=notif, drivers=registry, driver=driver,
1025
+ probe=request.cookies.get("kw_probe"), notifier=manager, default_buttons=DEFAULT_BUTTONS,
1026
+ jev=jev, jev_tally=jev_tally, jev_configured=semantic.configured(),
1027
+ jev_trust_selectable=semantic.TRUST_SELECTABLE)
1028
+
1029
+
1030
+ @router.post("/ui/settings/semantic", dependencies=[Depends(auth.require_admin)])
1031
+ async def settings_semantic(request: Request):
1032
+ form = await request.form()
1033
+ try:
1034
+ value = semantic.validate_config({k: form.get(k) for k in ("mode", "timeout_s", "daily_cap")})
1035
+ except ValueError as e:
1036
+ return redirect("/ui/settings", str(e))
1037
+ async with session() as db:
1038
+ await put_setting(db, "semantic", value)
1039
+ return redirect("/ui/settings", "Saved.")
1040
+
1041
+
1042
+ @router.post("/ui/settings/general", dependencies=[Depends(auth.require_admin)])
1043
+ async def settings_general(public_url: str = Form(""), outbound_ip: str = Form("")):
1044
+ if outbound_ip.strip():
1045
+ try:
1046
+ ipaddress.ip_address(outbound_ip.strip())
1047
+ except ValueError:
1048
+ return redirect("/ui/settings", "Outbound IP must be one IPv4 or IPv6 address.")
1049
+ async with session() as db:
1050
+ await put_setting(db, "general", {"public_url": public_url.strip().rstrip("/"), "outbound_ip": outbound_ip.strip()})
1051
+ return redirect("/ui/settings", "Saved.")
1052
+
1053
+
1054
+ async def _read_notif_form(request: Request) -> dict[str, Any]:
1055
+ form = await request.form()
1056
+ driver_id = str(form.get("driver") or "log")
1057
+ cls = registry.get(driver_id, registry["log"])
1058
+ async with session() as db:
1059
+ current = await get_setting(db, "notifications", {})
1060
+ old_cfg = (current.get("config") or {}) if current.get("driver") == driver_id else {}
1061
+ cfg: dict[str, Any] = {}
1062
+ for f in cls.fields:
1063
+ if f.type == "multiselect":
1064
+ cfg[f.name] = [str(v) for v in form.getlist(f.name)]
1065
+ elif f.type == "checkbox":
1066
+ cfg[f.name] = form.get(f.name) == "on"
1067
+ else:
1068
+ val = str(form.get(f.name) or "").strip()
1069
+ if f.type == "password" and val == "***":
1070
+ val = old_cfg.get(f.name, "")
1071
+ cfg[f.name] = val
1072
+ buttons = [b for b in form.getlist("buttons") if b in approvals.PHONE_SCOPES] or DEFAULT_BUTTONS
1073
+ return {"driver": driver_id, "config": cfg, "buttons": buttons, "choices": current.get("choices") or {}}
1074
+
1075
+
1076
+ @router.post("/ui/settings/notifications", dependencies=[Depends(auth.require_admin)])
1077
+ async def settings_notifications(request: Request):
1078
+ cfg = await _read_notif_form(request)
1079
+ async with session() as db:
1080
+ await put_setting(db, "notifications", cfg)
1081
+ await manager.reload()
1082
+ return redirect("/ui/settings", f"Notifications saved: {registry[cfg['driver']].label}.")
1083
+
1084
+
1085
+ @router.post("/ui/settings/notifications/probe", dependencies=[Depends(auth.require_admin)])
1086
+ async def settings_notifications_probe(request: Request):
1087
+ cfg = await _read_notif_form(request)
1088
+ cls = registry[cfg["driver"]]
1089
+ result = await cls(cfg["config"]).probe()
1090
+ if result.ok:
1091
+ cfg["choices"] = {k: [list(x) for x in v] for k, v in result.choices.items()}
1092
+ async with session() as db:
1093
+ await put_setting(db, "notifications", cfg)
1094
+ await manager.reload()
1095
+ resp = redirect("/ui/settings", ("Probe OK: " if result.ok else "Probe failed: ") + result.message)
1096
+ return resp
1097
+
1098
+
1099
+ @router.post("/ui/settings/notifications/test", dependencies=[Depends(auth.require_admin)])
1100
+ async def settings_notifications_test():
1101
+ async with session() as db:
1102
+ agent = (await db.execute(select(Agent).order_by(Agent.created_at).limit(1))).scalar_one_or_none()
1103
+ if agent is None:
1104
+ return redirect("/ui/settings", "Create an agent first; the test sends a real request in its name.")
1105
+ req = await approvals.create(db, agent, None, ["ssh/example/exec/uptime"],
1106
+ "Test notification from Keywright settings", {"command": "uptime"})
1107
+ req.expires_at = now() + timedelta(minutes=10)
1108
+ await db.commit()
1109
+ err = manager.last_error
1110
+ return redirect("/ui/settings", f"Test request {req.id} sent via {manager.driver_id}." + (f" ERROR: {err}" if err else ""))
1111
+
1112
+
1113
+ # ---- audit --------------------------------------------------------------------------------------
1114
+ @router.get("/ui/audit", response_class=HTMLResponse, dependencies=[Depends(auth.require_admin)])
1115
+ async def audit_list(request: Request, q: str = "", limit: int = 200):
1116
+ async with session() as db:
1117
+ stmt = select(AuditEvent).order_by(desc(AuditEvent.id)).limit(min(limit, 1000))
1118
+ if q.startswith("#") and q[1:].isdigit():
1119
+ # "audit #id" links must resolve however old the event is.
1120
+ stmt = select(AuditEvent).where(AuditEvent.id == int(q[1:]))
1121
+ rows = (await db.execute(stmt)).scalars().all()
1122
+ if q:
1123
+ ql = q.lower()
1124
+ rows = [r for r in rows if ql in (r.agent_name + r.tool + r.decision + " ".join(r.patterns) + str(r.detail)).lower()
1125
+ or ql.lstrip("#") == str(r.id)]
1126
+ return render(request, "audit.html", rows=rows, q=q)