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,37 @@
1
+ """Admin-only network map; controller reads share discovery's audit and locking."""
2
+ from fastapi import APIRouter, Depends, Request
3
+ from fastapi.responses import JSONResponse
4
+
5
+ from .. import discovery, inventory, network_map
6
+ from ..db import session
7
+ from . import auth
8
+ from .admin import render
9
+ from .security import require_same_origin
10
+
11
+ router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
12
+
13
+
14
+ @router.get('/ui/inventory/map')
15
+ async def network_map_page(request: Request, source: str = ''):
16
+ async with session() as db:
17
+ graph = await network_map.snapshot(db, source)
18
+ return render(request, 'inventory_map.html', graph=graph)
19
+
20
+
21
+ @router.get('/ui/inventory/map/data')
22
+ async def network_map_data(source: str = ''):
23
+ async with session() as db:
24
+ return await network_map.snapshot(db, source)
25
+
26
+
27
+ @router.post('/ui/inventory/map/refresh')
28
+ async def network_map_refresh(request: Request):
29
+ form = await request.form()
30
+ try:
31
+ source = inventory.value(form, 'resource_id', required=True)
32
+ run = await discovery.refresh(source, 'omada', min_interval=network_map.REFRESH_SECONDS)
33
+ except (discovery.DiscoveryError, inventory.InventoryError) as exc:
34
+ return JSONResponse({'error': str(exc)}, status_code=409)
35
+ async with session() as db:
36
+ graph = await network_map.snapshot(db, source)
37
+ return JSONResponse(graph, status_code=200 if run.status == 'succeeded' else 502)
@@ -0,0 +1,231 @@
1
+ """OIDC authorization code + PKCE login, with one-use flows and revocable local sessions."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import logging
8
+ import secrets
9
+ from datetime import timedelta
10
+ from urllib.parse import urlencode, urlsplit
11
+
12
+ import httpx2 as httpx
13
+ from authlib.integrations.base_client.errors import OAuthError
14
+ from authlib.integrations.starlette_client import OAuth
15
+ from fastapi import APIRouter, HTTPException, Request
16
+ from fastapi.responses import HTMLResponse, RedirectResponse
17
+ from joserfc.errors import JoseError
18
+ from sqlalchemy import delete
19
+
20
+ from .. import audit
21
+ from ..config import settings
22
+ from ..db import session
23
+ from ..models import AdminLoginFlow, AdminSession, now
24
+ from . import auth
25
+ from .security import _origin, safe_redirect
26
+
27
+ router = APIRouter()
28
+ log = logging.getLogger("keywright.oidc")
29
+ FLOW_COOKIE = "kw_oidc_flow"
30
+ FLOW_PATH = "/ui/oidc"
31
+ FLOW_TTL = 300
32
+
33
+
34
+ class CallbackLogFilter(logging.Filter):
35
+ """Uvicorn includes the query string in access logs; authorization codes stay out."""
36
+
37
+ def filter(self, record):
38
+ if isinstance(record.args, tuple) and len(record.args) == 5:
39
+ address, method, target, version, status = record.args
40
+ if isinstance(target, str) and target.split("?", 1)[0] in (
41
+ "/ui/oidc/callback", "/ui/mcp-oauth/callback",
42
+ ):
43
+ record.args = (address, method, target.split("?", 1)[0], version, status)
44
+ return True
45
+
46
+
47
+ def digest(value: str) -> str:
48
+ return hashlib.sha256(value.encode()).hexdigest()
49
+
50
+
51
+ def config_key() -> str:
52
+ return digest(json.dumps([settings.oidc_issuer, settings.oidc_client_id,
53
+ settings.oidc_client_secret, settings.oidc_admin_group,
54
+ settings.public_url, settings.oidc_session_ttl_s]))
55
+
56
+
57
+ def https_url(value: str) -> bool:
58
+ try:
59
+ url = urlsplit(value)
60
+ return bool(url.scheme == "https" and url.hostname and url.port != 0
61
+ and not url.username and not url.password and not url.fragment
62
+ and not url.query and not any(c.isspace() for c in value))
63
+ except ValueError:
64
+ return False
65
+
66
+
67
+ def configure(app) -> None:
68
+ access_log = logging.getLogger("uvicorn.access")
69
+ if not any(isinstance(item, CallbackLogFilter) for item in access_log.filters):
70
+ access_log.addFilter(CallbackLogFilter())
71
+ if settings.auth_mode not in ("password", "both", "oidc"):
72
+ raise ValueError("KW_AUTH_MODE must be password, both, or oidc")
73
+ if not auth.oidc_enabled():
74
+ app.state.oidc = None
75
+ return
76
+ if (not https_url(settings.oidc_issuer) or not https_url(settings.public_url)
77
+ or urlsplit(settings.public_url).path not in ("", "/")):
78
+ raise ValueError("OIDC requires an HTTPS KW_OIDC_ISSUER and an HTTPS origin in KW_PUBLIC_URL")
79
+ if not all((settings.oidc_client_id, settings.oidc_client_secret, settings.oidc_admin_group)):
80
+ raise ValueError("OIDC requires KW_OIDC_CLIENT_ID, KW_OIDC_CLIENT_SECRET, and KW_OIDC_ADMIN_GROUP")
81
+ if not 300 <= settings.oidc_session_ttl_s <= 86400:
82
+ raise ValueError("KW_OIDC_SESSION_TTL must be between 300 and 86400 seconds")
83
+ # Authlib handles discovery, PKCE, code exchange, JWKS rotation and ID token validation.
84
+ # Flow persistence is ours: no session middleware containing OAuth material in cookies.
85
+ app.state.oidc = OAuth().register(
86
+ "authentik", client_id=settings.oidc_client_id, client_secret=settings.oidc_client_secret,
87
+ server_metadata_url=settings.oidc_issuer.rstrip("/") + "/.well-known/openid-configuration",
88
+ client_kwargs={"scope": "openid profile email", "code_challenge_method": "S256",
89
+ "token_endpoint_auth_method": "client_secret_basic", "timeout": 10.0},
90
+ )
91
+
92
+
93
+ def callback_url() -> str:
94
+ # Fixed operator-configured origin, never derived from Host or forwarding headers.
95
+ return settings.public_url.rstrip("/") + "/ui/oidc/callback"
96
+
97
+
98
+ async def provider(request: Request):
99
+ if not auth.oidc_enabled():
100
+ raise HTTPException(404)
101
+ client = request.app.state.oidc
102
+ metadata = await client.load_server_metadata()
103
+ if metadata.get("issuer") != settings.oidc_issuer:
104
+ raise ValueError("OIDC discovery issuer mismatch")
105
+ for name in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
106
+ if not https_url(metadata.get(name, "")):
107
+ raise ValueError("OIDC discovery requires HTTPS endpoints")
108
+ # Only asymmetric ID token signatures, even if discovery advertises other algorithms.
109
+ supported = metadata.get("id_token_signing_alg_values_supported", ["RS256"])
110
+ algorithms = [alg for alg in supported if alg in ("RS256", "RS384", "RS512", "ES256", "ES384", "ES512")]
111
+ if not algorithms:
112
+ raise ValueError("OIDC provider has no supported signing algorithm")
113
+ metadata["id_token_signing_alg_values_supported"] = algorithms
114
+ return client
115
+
116
+
117
+ def failure(message: str, status: int = 400):
118
+ # Deliberately do not reflect provider error strings, tokens, or exception messages.
119
+ response = HTMLResponse(
120
+ '<!doctype html><title>Keywright sign in</title><h1>Sign in unsuccessful</h1>'
121
+ f'<p>{message}</p><p><a href="/ui/login?local=1">Return to sign in</a></p>', status_code=status,
122
+ )
123
+ response.delete_cookie(FLOW_COOKIE, path=FLOW_PATH, secure=True, httponly=True, samesite="lax")
124
+ return response
125
+
126
+
127
+ @router.get("/ui/oidc/login")
128
+ async def login(request: Request, next: str = "/ui"):
129
+ if not auth.oidc_enabled():
130
+ raise HTTPException(404)
131
+ if _origin(str(request.url)) != _origin(settings.public_url):
132
+ # Aliases and direct LAN URLs must start on the callback's origin so its cookie arrives.
133
+ return RedirectResponse(settings.public_url.rstrip("/") + "/ui/oidc/login?" +
134
+ urlencode({"next": safe_redirect(next)}), status_code=303)
135
+ auth.limit_login(request)
136
+ state, browser, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(4))
137
+ try:
138
+ client = await provider(request)
139
+ result = await client.create_authorization_url(
140
+ callback_url(), state=state, nonce=nonce, code_verifier=verifier,
141
+ )
142
+ except (httpx.HTTPError, OAuthError, ValueError, KeyError, TypeError) as exc:
143
+ log.warning("OIDC login unavailable (%s)", type(exc).__name__)
144
+ return failure("Authentik is unavailable. Try again shortly.", 503)
145
+ async with session() as db:
146
+ await db.execute(delete(AdminLoginFlow).where(AdminLoginFlow.expires_at <= now()))
147
+ await db.execute(delete(AdminSession).where(AdminSession.expires_at <= now()))
148
+ db.add(AdminLoginFlow(
149
+ state_hash=digest(state), browser_hash=digest(browser), config_key=config_key(),
150
+ data={"nonce": nonce, "verifier": verifier, "next": safe_redirect(next)},
151
+ expires_at=now() + timedelta(seconds=FLOW_TTL),
152
+ ))
153
+ await db.commit()
154
+ response = RedirectResponse(result["url"], status_code=302)
155
+ response.set_cookie(FLOW_COOKIE, browser, max_age=FLOW_TTL, path=FLOW_PATH,
156
+ secure=True, httponly=True, samesite="lax")
157
+ return response
158
+
159
+
160
+ @router.get("/ui/oidc/callback")
161
+ async def callback(request: Request):
162
+ if not auth.oidc_enabled():
163
+ raise HTTPException(404)
164
+ state = request.query_params.get("state", "")
165
+ browser = request.cookies.get(FLOW_COOKIE, "")
166
+ if not state or not browser or len(state) > 256 or len(browser) > 256:
167
+ return failure("Your sign-in attempt expired or could not be verified. Please start again.")
168
+ # Atomic consumption prevents replay and concurrent exchanges, including across workers.
169
+ async with session() as db:
170
+ flow = (await db.execute(delete(AdminLoginFlow).where(
171
+ AdminLoginFlow.state_hash == digest(state), AdminLoginFlow.browser_hash == digest(browser),
172
+ AdminLoginFlow.config_key == config_key(), AdminLoginFlow.expires_at > now(),
173
+ ).returning(AdminLoginFlow.data))).scalar_one_or_none()
174
+ await db.commit()
175
+ if flow is None:
176
+ return failure("Your sign-in attempt expired or could not be verified. Please start again.")
177
+ if request.query_params.get("error") or not request.query_params.get("code"):
178
+ return failure("Sign in was cancelled or denied by Authentik.")
179
+ try:
180
+ client = await provider(request)
181
+ token = await client.fetch_access_token(
182
+ redirect_uri=callback_url(), code=request.query_params["code"], code_verifier=flow["verifier"],
183
+ )
184
+ claims = await client.parse_id_token(
185
+ token, nonce=flow["nonce"], leeway=30,
186
+ claims_options={"iss": {"essential": True, "value": settings.oidc_issuer}},
187
+ )
188
+ if not claims or not isinstance(claims.get("sub"), str) or not claims["sub"]:
189
+ raise ValueError("Missing subject")
190
+ groups = claims.get("groups")
191
+ if not isinstance(groups, list) or settings.oidc_admin_group not in groups:
192
+ return failure("Your Authentik account does not have Keywright administrator access.", 403)
193
+ except (httpx.HTTPError, OAuthError, JoseError, ValueError, KeyError, TypeError) as exc:
194
+ log.warning("OIDC callback rejected (%s)", type(exc).__name__)
195
+ return failure("Authentik sign in could not be verified. Please start again.")
196
+ identity = {"method": "oidc", "issuer": claims["iss"], "subject": claims["sub"],
197
+ "name": str(claims.get("name") or claims.get("preferred_username") or claims["sub"])[:200]}
198
+ raw = secrets.token_urlsafe(32)
199
+ async with session() as db:
200
+ await revoke_session(request, db)
201
+ db.add(AdminSession(token_hash=digest(raw), config_key=config_key(), identity=identity,
202
+ expires_at=now() + timedelta(seconds=settings.oidc_session_ttl_s)))
203
+ await audit.record(db, tool="admin.login", decision="allow", detail={"identity": identity})
204
+ response = RedirectResponse(safe_redirect(flow["next"]), status_code=303)
205
+ response.set_cookie(auth.COOKIE, "oidc:" + raw, max_age=settings.oidc_session_ttl_s,
206
+ secure=True, httponly=True, samesite="lax")
207
+ response.delete_cookie(FLOW_COOKIE, path=FLOW_PATH, secure=True, httponly=True, samesite="lax")
208
+ return response
209
+
210
+
211
+ async def session_identity(request) -> dict | None:
212
+ raw = request.cookies.get(auth.COOKIE, "")
213
+ if not raw.startswith("oidc:") or len(raw) > 256:
214
+ return None
215
+ async with session() as db:
216
+ row = await db.get(AdminSession, digest(raw[5:]))
217
+ if row and row.expires_at > now() and hmac.compare_digest(row.config_key, config_key()):
218
+ return dict(row.identity)
219
+ return None
220
+
221
+
222
+ async def revoke_session(request, db) -> None:
223
+ raw = request.cookies.get(auth.COOKIE, "")
224
+ if raw.startswith("oidc:") and len(raw) <= 256:
225
+ await db.execute(delete(AdminSession).where(AdminSession.token_hash == digest(raw[5:])))
226
+
227
+
228
+ async def logout(request) -> None:
229
+ async with session() as db:
230
+ await revoke_session(request, db)
231
+ await db.commit()
@@ -0,0 +1,77 @@
1
+ """Browser request boundaries and response headers for the admin UI."""
2
+ from __future__ import annotations
3
+
4
+ from urllib.parse import unquote, urlsplit
5
+
6
+ from fastapi import HTTPException, Request
7
+ from starlette.datastructures import MutableHeaders
8
+
9
+
10
+ def _origin(value: str) -> tuple[str, str, int] | None:
11
+ try:
12
+ url = urlsplit(value)
13
+ if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password:
14
+ return None
15
+ return url.scheme, url.hostname.lower(), url.port or (443 if url.scheme == "https" else 80)
16
+ except ValueError:
17
+ return None
18
+
19
+
20
+ async def require_same_origin(request: Request) -> None:
21
+ """All browser mutations need a matching Origin or Referer; absence fails closed.
22
+
23
+ Native scripts using admin cookies must supply Origin explicitly. MCP uses bearer auth
24
+ through its own ASGI route and does not use browser cookies or this dependency.
25
+ """
26
+ if request.method in ("GET", "HEAD", "OPTIONS"):
27
+ return
28
+ source = request.headers.get("origin") or request.headers.get("referer")
29
+ if request.headers.get("sec-fetch-site") in ("cross-site", "same-site"):
30
+ raise HTTPException(403, "Cross-origin form submission rejected")
31
+ if not source or _origin(source) is None or _origin(source) != _origin(str(request.url)):
32
+ raise HTTPException(403, "A same-origin Origin or Referer header is required")
33
+
34
+
35
+ def websocket_same_origin(origin: str | None, url: str) -> bool:
36
+ """A websocket handshake carries the admin cookie but never passes through require_same_origin,
37
+ which only sees HTTP methods. Browsers always send Origin on a websocket, so a missing or
38
+ mismatched one fails closed. ws:// and wss:// are normalised to the scheme they upgrade from,
39
+ because that is what the browser puts in Origin."""
40
+ scheme, _, rest = url.partition("://")
41
+ upgraded = {"ws": "http", "wss": "https"}.get(scheme, scheme) + "://" + rest
42
+ return bool(origin) and _origin(origin) is not None and _origin(origin) == _origin(upgraded)
43
+
44
+
45
+ def safe_redirect(value: str) -> str:
46
+ decoded = unquote(value)
47
+ if (not decoded.startswith("/") or decoded.startswith("//") or "\\" in decoded
48
+ or any(ord(c) < 32 for c in decoded)):
49
+ return "/ui"
50
+ return value
51
+
52
+
53
+ class BrowserSecurityHeaders:
54
+ def __init__(self, app):
55
+ self.app = app
56
+
57
+ async def __call__(self, scope, receive, send):
58
+ if scope["type"] != "http":
59
+ return await self.app(scope, receive, send)
60
+
61
+ async def secure_send(message):
62
+ if message["type"] == "http.response.start":
63
+ headers = MutableHeaders(scope=message)
64
+ headers["X-Content-Type-Options"] = "nosniff"
65
+ headers["X-Frame-Options"] = "DENY"
66
+ headers["Referrer-Policy"] = "same-origin"
67
+ headers["Content-Security-Policy"] = "frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
68
+ if scope["path"].startswith(("/ui", "/d/", "/enroll/")):
69
+ headers["Cache-Control"] = "no-store"
70
+ if scope.get("scheme") == "https":
71
+ message["headers"] = [
72
+ (key, value + b"; Secure" if key.lower() == b"set-cookie" and b"; secure" not in value.lower()
73
+ else value) for key, value in message["headers"]
74
+ ]
75
+ await send(message)
76
+
77
+ await self.app(scope, receive, secure_send)
@@ -0,0 +1,25 @@
1
+ {% from "_icons.html" import icon %}
2
+ {% set failed = e.decision in ['deny', 'denied', 'locked', 'error', 'revoked', 'refused'] or (e.exit_code is not none and e.exit_code != 0) %}
3
+ {% set passed = e.decision in ['allow', 'approved', 'ok'] and not failed %}
4
+ <details class="activity-row" id="event-{{ e.id }}">
5
+ <summary>
6
+ <span class="activity-icon {{ 'failed' if failed else 'passed' if passed else '' }}">{{ icon('deny' if failed else 'check' if passed else 'activity') }}</span>
7
+ <span class="activity-copy"><span class="activity-title">{{ e.patterns[0] if e.patterns else e.tool }}</span><span class="activity-subtitle">{{ e.agent_name or 'System' }} · {{ e.tool }}{% if e.patterns|length > 1 %} · +{{ e.patterns|length - 1 }} scopes{% endif %}</span>{% if e.detail.hazards %}<span class="activity-flag">{{ icon('warning') }} {{ e.detail.hazards|length }} hazard{{ '' if e.detail.hazards|length == 1 else 's' }} flagged</span>{% endif %}</span>
8
+ <span class="activity-meta"><time datetime="{{ e.ts.isoformat() }}" title="{{ e.ts.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{% set age = (now - e.ts).total_seconds() | int %}{% if age < 60 %}just now{% elif age < 3600 %}{{ age // 60 }}m ago{% elif age < 86400 %}{{ age // 3600 }}h ago{% else %}{{ e.ts.strftime('%b %d') }}{% endif %}</time><span class="pill {{ e.decision }}">{{ e.decision }}</span>{% if e.exit_code is not none and e.exit_code != 0 %}<span class="activity-flag">exit {{ e.exit_code }}</span>{% endif %}</span>
9
+ </summary>
10
+ <div class="activity-detail">
11
+ <dl><div><dt>Event</dt><dd><a href="/ui/audit?q=%23{{ e.id }}">#{{ e.id }}</a></dd></div><div><dt>Time</dt><dd>{{ e.ts.strftime('%Y-%m-%d %H:%M:%S UTC') }}</dd></div><div><dt>Agent</dt><dd>{{ e.agent_name or 'System' }}</dd></div><div><dt>Tool</dt><dd>{{ e.tool }}</dd></div>{% if e.session_id %}<div><dt>Session</dt><dd class="mono">{{ e.session_id }}</dd></div>{% endif %}{% if e.exit_code is not none %}<div><dt>Result</dt><dd>exit {{ e.exit_code }}{% if e.duration_ms is not none %} · {{ e.duration_ms }} ms{% endif %}</dd></div>{% endif %}</dl>
12
+ {% for p in e.patterns %}<div class="activity-pattern"><code>{{ p }}</code></div>{% endfor %}
13
+ {% if e.detail.command %}<pre>$ {{ e.detail.command }}</pre>{% endif %}
14
+ {% if e.detail.hazards %}<div class="hazard">{% for h in e.detail.hazards %}<div><span class="pill {{ h.severity }}">{{ h.severity }}</span> {{ h.reason }}</div>{% endfor %}</div>{% endif %}
15
+ {% if e.detail.reason %}<p class="small mut">{{ e.detail.reason }}</p>{% endif %}
16
+ {% if e.detail.error %}<p class="small activity-flag">{{ e.detail.error }}</p>{% endif %}
17
+ {% set delivery = e.detail.get('result_delivery') %}
18
+ {% if delivery and delivery.mode != 'off' %}
19
+ <p class="small"><b>MCP result:</b> upstream {{ 'reported an error' if delivery.upstream_is_error else 'completed without a tool error' }} · delivery <b>{{ delivery.delivery }}</b> · inspection {{ delivery.mode }} · coverage {{ delivery.coverage }}.</p>
20
+ {% if delivery.delivery == 'withheld' %}<p class="small activity-flag">The tool already ran. Withholding its result does not undo its effects; do not automatically retry.</p>{% endif %}
21
+ {% if delivery.findings %}<p class="small">Findings: {{ delivery.findings | join(', ') }}. {{ delivery.matches }} credential matches. Instruction findings are advisory.</p>{% endif %}
22
+ {% if delivery.gaps %}<p class="small">Coverage gaps: {{ delivery.gaps | join(', ') }}.</p>{% endif %}
23
+ {% endif %}
24
+ </div>
25
+ </details>
@@ -0,0 +1,50 @@
1
+ <section class="card connection-guide" aria-labelledby="connect-heading">
2
+ <h2 id="connect-heading" style="margin-top:0">Connect an agent</h2>
3
+ <p class="small mut">Run these commands on the machine where your agent runs.{% if not new_token %} Replace <code>kw_YOUR_AGENT_TOKEN</code> with your saved token.{% endif %}</p>
4
+ <details {% if new_token %}open{% endif %}>
5
+ <summary>Any agent: the <code>kw</code> command (recommended)</summary>
6
+ <p class="small">Works from any shell, waits out phone approvals, and scopes "this session" approvals per conversation. Install the client, store the token, set the agent up:</p>
7
+ <pre>pipx install keywright
8
+ kw login {{ mcp_url | shellquote }} --paste # then paste the token on its own line
9
+ kw setup claude-code # or: kw setup codex
10
+ kw setup check</pre>
11
+ <p class="small mut">Agents then run <code>kw resources</code>, <code>kw run &lt;host&gt; -r "why" -- &lt;command&gt;</code>, <code>kw put</code>, <code>kw get</code> and <code>kw tool</code>. <code>kw guide</code> prints the full agent guide.</p>
12
+ </details>
13
+ <details>
14
+ <summary>Codex over MCP</summary>
15
+ <p class="small">Export the token, then register Keywright:</p>
16
+ <pre>export KEYWRIGHT_TOKEN={{ (new_token or 'kw_YOUR_AGENT_TOKEN') | shellquote }}
17
+ codex mcp add keywright \
18
+ --url {{ mcp_url | shellquote }} \
19
+ --bearer-token-env-var KEYWRIGHT_TOKEN</pre>
20
+ <p class="small">In <code>~/.codex/config.toml</code>, set <code>tool_timeout_sec = 180</code> in the Keywright section to leave time for phone approvals. The section should look like this:</p>
21
+ <pre>[mcp_servers.keywright]
22
+ url = {{ mcp_url | tojson }}
23
+ bearer_token_env_var = "KEYWRIGHT_TOKEN"
24
+ tool_timeout_sec = 180</pre>
25
+ <p class="small mut">Start Codex from the shell where <code>KEYWRIGHT_TOKEN</code> is exported. Set it again in new shells, or supply it through your environment manager. For the IDE extension, the variable must be available to the IDE process. Use <code>/mcp</code> in Codex to check the connection.</p>
26
+ <p class="small"><a href="https://developers.openai.com/codex/mcp/" target="_blank" rel="noreferrer">Codex MCP documentation ↗</a></p>
27
+ </details>
28
+ <details>
29
+ <summary>Claude Code over MCP</summary>
30
+ <pre>claude mcp add --transport http keywright \
31
+ {{ mcp_url | shellquote }} \
32
+ --header {{ ('Authorization: Bearer ' ~ (new_token or 'kw_YOUR_AGENT_TOKEN')) | shellquote }}</pre>
33
+ </details>
34
+ {% if gateways %}
35
+ <details>
36
+ <summary>LLM calls through Keywright <span class="small mut">(no provider key on the agent's machine)</span></summary>
37
+ <p class="small">Point the SDK or the agent at a gateway with the same token; every call names a model, and the model is what you approve. <code>kw llm &lt;gateway&gt;</code> prints these lines from a stored profile.</p>
38
+ {% for g in gateways %}<pre># {{ g.name }}: {{ (g.config.models or []) | join(', ') or 'no models listed yet' }}
39
+ export ANTHROPIC_BASE_URL={{ (public_url ~ '/llm/' ~ g.name) | shellquote }}
40
+ export ANTHROPIC_AUTH_TOKEN={{ (new_token or 'kw_YOUR_AGENT_TOKEN') | shellquote }}
41
+ export OPENAI_BASE_URL={{ (public_url ~ '/llm/' ~ g.name ~ '/v1') | shellquote }}
42
+ export OPENAI_API_KEY={{ (new_token or 'kw_YOUR_AGENT_TOKEN') | shellquote }}</pre>{% endfor %}
43
+ <p class="small mut">Unset the real <code>ANTHROPIC_API_KEY</code> / <code>OPENAI_API_KEY</code> there, and rotate them: a key that was ever on the agent's machine is spent.</p>
44
+ </details>
45
+ {% endif %}
46
+ <details>
47
+ <summary>Other MCP clients</summary>
48
+ <p class="small">Use streamable HTTP at <code>{{ mcp_url }}</code> with an <code>Authorization: Bearer &lt;agent token&gt;</code> header.</p>
49
+ </details>
50
+ </section>
@@ -0,0 +1,88 @@
1
+ /* Compact inventory rows and audit records use flat surfaces and shared spacing. */
2
+ .ui-icon{display:block;flex-shrink:0}
3
+ .collection-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 6px}
4
+ .collection-heading h1{margin:0}
5
+ .collection-intro{margin:0 0 18px;line-height:1.5}
6
+ .action-menu{position:relative;flex-shrink:0}
7
+ .action-menu>summary{list-style:none;cursor:pointer}
8
+ .action-menu>summary::-webkit-details-marker{display:none}
9
+ .resource-add>summary{white-space:nowrap}
10
+ .action-menu-items{position:absolute;right:0;top:calc(100% + 4px);min-width:190px;padding:4px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel);box-shadow:0 6px 18px #0003;z-index:4}
11
+ .action-menu-items a,.action-menu-items button{display:flex;align-items:center;gap:8px;width:100%;min-height:32px;padding:6px 8px;text-align:left;border:0;border-radius:2px;background:none;color:var(--fg);font:inherit;cursor:pointer}
12
+ .action-menu-items .ui-icon{width:18px;height:18px}
13
+ .action-menu-items a:hover,.action-menu-items button:hover{background:var(--panel-raised);text-decoration:none}
14
+ #resource-filters{display:flex;gap:16px;align-items:center;justify-content:space-between;margin-bottom:12px}
15
+ .resource-tabs{display:flex;gap:0;align-self:stretch;border-bottom:1px solid var(--line)}
16
+ .resource-tabs button{min-height:34px;padding:6px 16px;border:0;border-bottom:2px solid transparent;background:none;color:var(--mut);font:inherit;cursor:pointer}
17
+ .resource-tabs button:hover{color:var(--fg);background:var(--panel)}
18
+ .resource-tabs button[aria-pressed=true]{border-bottom-color:var(--acc);color:var(--fg);font-weight:600}
19
+ .resource-search{display:flex;align-items:center;gap:8px;min-height:34px;max-width:420px;flex:1;padding:0 10px;border:1px solid var(--line);border-radius:var(--r);background:var(--panel);color:var(--mut)}
20
+ .resource-search .ui-icon{width:16px;height:16px}
21
+ .resource-search:focus-within{outline:2px solid var(--acc);outline-offset:2px}
22
+ .resource-search input{width:100%;min-width:0;min-height:32px;padding:5px 0;border:0;background:none;color:var(--fg);font:inherit;outline:none}
23
+ .resource-search input::placeholder{color:var(--mut);opacity:1}
24
+ .resource-list{display:grid;grid-template-columns:minmax(0,1fr);border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}
25
+ .resource-list:not(:has(.resource-card:not([hidden]))){border:0;background:none}
26
+ .resource-card{position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(180px,28%) 32px;gap:4px 20px;align-items:center;padding:12px 14px}
27
+ .resource-card:not([hidden])~.resource-card:not([hidden]){border-top:1px solid var(--line)}
28
+ .resource-card:has(.resource-link:hover){background:var(--panel-raised)}
29
+ .resource-link{grid-column:1;grid-row:1;display:grid;grid-template-columns:22px minmax(0,1fr);align-items:start;gap:10px;color:var(--fg);border-radius:2px}
30
+ .resource-link:hover{text-decoration:none}
31
+ .resource-icon{display:flex;align-items:center;justify-content:center;width:22px;height:22px;color:var(--mut)}
32
+ .resource-icon .ui-icon{width:20px;height:20px}
33
+ .resource-copy{min-width:0}.resource-copy h2{margin:0 0 2px;font-size:14px;font-weight:600;line-height:1.4;overflow-wrap:anywhere}
34
+ .resource-endpoint{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--mut);line-height:1.5;overflow-wrap:anywhere}
35
+ .resource-chevron{display:none}
36
+ .resource-labels{grid-column:2;grid-row:1/3;display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;font-size:12px}
37
+ .resource-mode{display:inline-flex;align-items:center;gap:6px;color:var(--mut)}
38
+ .resource-mode>span{width:6px;height:6px;flex-shrink:0;border-radius:50%;background:currentColor}
39
+ .resource-mode.open{color:var(--ok)}.resource-mode.approval,.resource-warning{color:var(--warn)}.resource-mode.locked{color:var(--bad)}
40
+ .resource-description{grid-column:1;grid-row:2;margin:0 0 0 32px;color:var(--mut);font-size:12px;line-height:1.45;overflow-wrap:anywhere}
41
+ .resource-actions{grid-column:3;grid-row:1/3;align-self:center}
42
+ .resource-actions>summary{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:var(--r);color:var(--mut)}
43
+ .resource-actions .ui-icon{width:18px;height:18px}
44
+ .resource-actions>summary:hover,.resource-actions[open]>summary{background:var(--panel-raised);color:var(--fg)}
45
+ .resource-count{margin:10px 0;text-align:right;font-variant-numeric:tabular-nums}
46
+ .resource-help{color:var(--mut);border-top:1px solid var(--line);margin-top:16px;font-size:12px}
47
+ .resource-help>summary{cursor:pointer;padding:10px 0;min-height:36px}
48
+ .resource-help p{margin:0 0 10px}
49
+ .collection-empty{padding:24px;text-align:center;grid-column:1/-1}
50
+ .collection-empty>.ui-icon{margin:auto;color:var(--mut);width:24px;height:24px}
51
+ .collection-empty .row{justify-content:center}
52
+ .activity-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:20px 0 8px}
53
+ .activity-heading h2{margin:0;font-size:15px}.activity-heading>a{font-size:12px}
54
+ .activity-list.card{padding:0 12px;background:var(--panel)}
55
+ .activity-row+.activity-row{border-top:1px solid var(--line)}
56
+ .activity-row>summary{display:grid;grid-template-columns:20px minmax(0,1fr) auto;gap:10px;align-items:center;min-height:56px;padding:9px 0;list-style:none;cursor:pointer}
57
+ .activity-row>summary::-webkit-details-marker{display:none}
58
+ .activity-row>summary:hover{background:var(--panel-raised)}
59
+ .activity-icon{display:flex;align-items:center;justify-content:center;width:20px;height:20px;color:var(--mut)}
60
+ .activity-icon .ui-icon{width:18px;height:18px}
61
+ .activity-icon.passed{color:var(--ok)}.activity-icon.failed{color:var(--bad)}
62
+ .activity-copy{min-width:0}.activity-title{display:block;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:500}
63
+ .activity-subtitle{display:block;font-size:12px;color:var(--mut);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
64
+ .activity-meta{display:flex;flex-direction:column;align-items:flex-end;gap:3px;font-size:12px;color:var(--mut);font-variant-numeric:tabular-nums}
65
+ .activity-meta time{white-space:nowrap}.activity-meta .pill{font-size:11px;padding:0 5px;max-width:115px;white-space:normal;overflow-wrap:anywhere;text-align:right}
66
+ .activity-detail{padding:0 0 12px 30px;font-size:13px}
67
+ .activity-row[open] .activity-title,.activity-row[open] .activity-subtitle{white-space:normal;overflow-wrap:anywhere}
68
+ .activity-detail dl{margin:0 0 10px}.activity-detail dl>div{display:grid;grid-template-columns:64px minmax(0,1fr);gap:10px;margin:5px 0}
69
+ .activity-detail dt{color:var(--mut)}.activity-detail dd{margin:0;overflow-wrap:anywhere}
70
+ .activity-pattern{margin:6px 0;overflow-wrap:anywhere}
71
+ .activity-flag{color:var(--bad);font-size:12px}.activity-flag .ui-icon{display:inline-block;vertical-align:-2px;width:14px;height:14px}
72
+ .activity-all{display:flex;margin:2px 0 8px;color:var(--mut);border:0}
73
+ [hidden]{display:none!important}
74
+ @media(max-width:900px){
75
+ .action-menu-items a,.action-menu-items button{min-height:44px}
76
+ .collection-heading h1{font-size:20px}.collection-intro{font-size:13px;margin-bottom:14px}
77
+ #resource-filters{flex-wrap:wrap;gap:10px}.resource-tabs{width:100%}.resource-tabs button{min-height:40px;flex:1}
78
+ .resource-search{max-width:none;flex-basis:100%;min-height:40px}.resource-search input{font-size:16px;min-height:38px}
79
+ .resource-card{grid-template-columns:minmax(0,1fr) 40px;gap:5px 8px;padding:12px}
80
+ .resource-labels{grid-column:1;grid-row:3;margin-left:32px}.resource-actions{grid-column:2;grid-row:1/4}
81
+ .resource-actions>summary{width:40px;height:44px}
82
+ .activity-list.card{padding:0 10px}
83
+ }
84
+ @media(max-width:360px){
85
+ .collection-heading{gap:8px}.resource-add>summary{font-size:12px;padding:5px 8px}
86
+ .resource-card{padding:10px}.activity-row>summary{gap:8px}
87
+ .activity-detail{padding-left:0}
88
+ }
@@ -0,0 +1,6 @@
1
+ {% if constraints.host %}<div>Host: <b>{{ constraints.host }}</b>{% if constraints.user %} · as <code>{{ constraints.user }}</code>{% endif %}</div>{% endif %}
2
+ {% if constraints.server %}<div>Server: <b>{{ constraints.server }}</b>{% if constraints.tool %} · <code>{{ constraints.tool }}</code>{% endif %}</div>{% endif %}
3
+ {% if constraints.command %}<pre>$ {{ constraints.command }}</pre>{% endif %}
4
+ {% if constraints.to_path %}<div>Output {{ 'appended to' if constraints.append else 'written to' }} <b>{{ constraints.to_host }}</b> <code>{{ constraints.to_path }}</code>{% if constraints.to_user %} as <code>{{ constraints.to_user }}</code>{% endif %} · not returned to the agent</div>{% endif %}
5
+ {% if constraints.sha256 %}<div>Write <code>{{ constraints.path }}</code> · {{ constraints.bytes }} bytes · sha256 <code>{{ constraints.sha256[:16] }}…</code></div>{% elif constraints.path %}<div>Read <code>{{ constraints.path }}</code></div>{% endif %}
6
+ {% if 'arguments' in constraints %}<pre>{{ constraints.arguments | redact | tojson(indent=2) }}</pre>{% endif %}
@@ -0,0 +1,39 @@
1
+ {# Scope is independent of lifetime. Broadening requires an authenticated admin decision. #}
2
+ <form method="post" action="/ui/requests/{{ req.id }}/decide" class="decision-actions">
3
+ <input type="hidden" name="next" value="{{ next or '/ui' }}">
4
+ {% if not in_detail %}{% include "_hazards.html" %}{% endif %}
5
+ {% if hazard_blocks(hazard_level(req.detail)) %}
6
+ <input type="hidden" name="extent" value="exact">
7
+ <button class="btn ok" name="scope" value="once">Allow once (exact operation)</button>
8
+ <div class="decision-deny"><input type="text" name="deny_reason" placeholder="Deny reason (optional)" aria-label="Deny reason (optional)">
9
+ <button class="btn bad" name="scope" value="deny">Deny</button></div>
10
+ <div class="decision-scope row"><button class="btn" name="scope" value="deny_always" onclick="return confirm('Create a standing deny rule for this exact operation?')">Always deny</button><span class="help">Flagged destructive: no timed, session or standing allow is offered.</span></div>
11
+ </form>
12
+ {% else %}
13
+ <div class="decision-scope">
14
+ <label for="extent-{{ req.id }}">What to authorize</label>
15
+ {% if binds_exactly(req.detail) %}
16
+ <select id="extent-{{ req.id }}" name="extent">
17
+ <option value="exact">Exact operation shown above</option>
18
+ <option value="patterns">Listed permission patterns, including other matching arguments</option>
19
+ </select>
20
+ <div class="help">Exact keeps the same command, file contents or tool arguments for every duration{% if operations(req.detail)|length > 1 %}, one grant per operation shown{% endif %}. Pattern access is broader{% if req.detail.user %}; it remains bound to {{ req.detail.user }} on this target{% endif %}.</div>
21
+ {% else %}
22
+ <input type="hidden" name="extent" value="patterns">
23
+ <div>Listed permission patterns, including all matching arguments.</div>
24
+ {% endif %}
25
+ <label>How long to allow it</label>
26
+ </div>
27
+ <button class="btn ok" name="scope" value="once">Allow once</button>
28
+ <button class="btn" name="scope" value="ttl15">15 minutes</button>
29
+ {% if path_scoped(req.detail) %}<button class="btn" name="scope" value="path15" title="Any contents at this path, for 15 minutes">This path, 15 min</button>{% endif %}
30
+ <button class="btn" name="scope" value="ttl30">30 minutes</button>
31
+ <button class="btn" name="scope" value="ttl">1 hour</button>
32
+ {% if req.session_id %}<button class="btn" name="scope" value="session">This session</button>{% endif %}
33
+ <button class="btn" name="scope" value="always" onclick="return confirm('Create a standing allow rule for the selected scope?')">Always allow</button>
34
+ <div class="decision-scope help">{% if req.session_id %}Session access is tied to the client-supplied session ID and expires within {{ session_cap_minutes }} minutes.{% else %}No session ID was provided; use once or timed access.{% endif %} Once applies to each listed permission scope. Expiry and revocation block future calls; running operations continue.</div>
35
+ <div class="decision-deny"><input type="text" name="deny_reason" placeholder="Deny reason (optional)" aria-label="Deny reason (optional)">
36
+ <button class="btn bad" name="scope" value="deny">Deny</button></div>
37
+ <div class="decision-scope row"><button class="btn" name="scope" value="deny_always" onclick="return confirm('Create a standing deny rule for the selected scope?')">Always deny</button><span class="help">Deny rejects this request and suppresses equivalent requests for 10 minutes. Always deny creates a rule.</span></div>
38
+ </form>
39
+ {% endif %}
@@ -0,0 +1,66 @@
1
+ <style>
2
+ .device-kind{min-width:0}.device-kind.enhanced>select{display:none}.kind-input-row{display:flex;gap:6px}.kind-input-row input{min-width:0;width:100%;flex:1;font-size:16px}.kind-input-row .btn{flex:0 0 44px;min-width:44px;min-height:44px;padding:6px}.kind-results{border:1px solid var(--line);border-radius:6px;background:var(--panel);margin-top:6px;max-height:280px;max-height:min(280px,32svh);overflow:auto;overscroll-behavior:contain}.kind-option{display:block;width:100%;border:0;border-bottom:1px solid var(--line);border-radius:0;padding:11px 12px;min-height:44px;text-align:left;background:transparent;color:var(--fg);font:inherit;cursor:pointer;overflow-wrap:anywhere}.kind-option:last-child{border-bottom:0}.kind-option.active,.kind-option:hover{background:rgba(80,169,255,.15);color:var(--acc)}.kind-option[aria-selected=true]{font-weight:600}.kind-empty{padding:12px;margin:0;color:var(--mut)}.kind-help{margin-top:6px;font-size:12px;color:var(--mut)}
3
+ </style>
4
+ <script>
5
+ (()=>{
6
+ 'use strict';
7
+ for(const root of document.querySelectorAll('[data-device-kind]')){
8
+ const select=root.querySelector('select'),id=select.id;
9
+ const choices=[...select.options].map(o=>({value:o.value,label:o.textContent}));
10
+ const make=(tag,cls)=>{const n=document.createElement(tag);if(cls)n.className=cls;return n;};
11
+ const row=make('div','kind-input-row'),input=make('input'),toggle=make('button','btn');
12
+ const list=make('div','kind-results'),help=make('div','kind-help');
13
+ select.id=id+'-native';input.id=id;input.type='text';input.autocomplete='off';input.spellcheck=false;
14
+ input.setAttribute('role','combobox');input.setAttribute('aria-autocomplete','list');
15
+ input.setAttribute('aria-expanded','false');input.setAttribute('aria-controls',id+'-options');
16
+ input.setAttribute('aria-describedby',id+'-help');input.placeholder='Search device types…';
17
+ list.id=id+'-options';list.setAttribute('role','listbox');list.setAttribute('aria-label','Device types');list.hidden=true;
18
+ help.id=id+'-help';help.setAttribute('role','status');help.setAttribute('aria-live','polite');
19
+ toggle.type='button';toggle.textContent='⌄';toggle.setAttribute('aria-label','Browse all device types');
20
+ row.append(input,toggle);root.append(row,list,help);root.classList.add('enhanced');
21
+ let filtered=choices,active=-1,editing=false;
22
+ const selected=()=>choices.find(c=>c.value===select.value)||choices[0];
23
+ function close(restore=false){
24
+ list.hidden=true;input.setAttribute('aria-expanded','false');input.removeAttribute('aria-activedescendant');active=-1;
25
+ if(restore){input.value=selected().label;input.setCustomValidity('');editing=false;}
26
+ help.textContent=editing?'Choose a matching type, or press Escape to keep '+selected().label+'.':'Type to filter '+choices.length+' device types. Current: '+selected().label+'.';
27
+ }
28
+ function choose(choice){select.value=choice.value;select.dispatchEvent(new Event('change',{bubbles:true}));close(true);input.focus({preventScroll:true});}
29
+ function activate(index){
30
+ active=index;[...list.querySelectorAll('[role=option]')].forEach((n,i)=>n.classList.toggle('active',i===active));
31
+ const node=list.children[active];if(node&&filtered.length){input.setAttribute('aria-activedescendant',node.id);node.scrollIntoView({block:'nearest'});}else input.removeAttribute('aria-activedescendant');
32
+ }
33
+ function show(query=''){
34
+ const words=query.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
35
+ filtered=choices.filter(c=>words.every(word=>(c.label+' '+c.value.replaceAll('_',' ')).toLocaleLowerCase().includes(word)));
36
+ list.replaceChildren();active=-1;input.removeAttribute('aria-activedescendant');
37
+ for(const [i,choice] of filtered.entries()){
38
+ const option=make('button','kind-option');option.type='button';option.tabIndex=-1;
39
+ option.id=id+'-option-'+i;option.setAttribute('role','option');option.setAttribute('aria-selected',String(choice.value===select.value));option.textContent=choice.label;
40
+ option.addEventListener('pointerdown',e=>e.preventDefault());option.onclick=()=>choose(choice);list.append(option);
41
+ }
42
+ if(!filtered.length){const empty=make('p','kind-empty');empty.textContent='No matching types. Try a broader term, or choose Other device.';list.append(empty);}
43
+ list.hidden=false;input.setAttribute('aria-expanded','true');help.textContent=filtered.length+' matching type'+(filtered.length===1?'':'s')+'. Select one to use it.';
44
+ }
45
+ close(true);
46
+ input.addEventListener('focus',()=>{if(!editing)input.select();});
47
+ input.addEventListener('click',()=>{if(list.hidden)show(editing?input.value:'');});
48
+ input.addEventListener('input',()=>{
49
+ editing=true;input.setCustomValidity('Choose a device type from the matching options.');show(input.value);
50
+ const exact=choices.find(c=>c.label.toLocaleLowerCase()===input.value.trim().toLocaleLowerCase());
51
+ if(exact){select.value=exact.value;input.setCustomValidity('');editing=false;select.dispatchEvent(new Event('change',{bubbles:true}));}
52
+ });
53
+ input.addEventListener('keydown',e=>{
54
+ if(e.key==='ArrowDown'||e.key==='ArrowUp'){
55
+ e.preventDefault();if(list.hidden)show(editing?input.value:'');
56
+ if(filtered.length)activate(active<0?(e.key==='ArrowDown'?0:filtered.length-1):(active+(e.key==='ArrowDown'?1:-1)+filtered.length)%filtered.length);
57
+ }else if(e.key==='Enter'&&!list.hidden){e.preventDefault();if(filtered.length)choose(filtered[active<0?0:active]);}
58
+ else if(e.key==='Escape'){e.preventDefault();close(true);}
59
+ });
60
+ toggle.onclick=()=>{if(!list.hidden){close(true);return;}input.focus();input.select();show();};
61
+ root.addEventListener('focusout',()=>setTimeout(()=>{if(!root.contains(document.activeElement))close();},0));
62
+ document.addEventListener('pointerdown',e=>{if(!root.contains(e.target))close();});
63
+ select.form?.addEventListener('reset',()=>setTimeout(()=>close(true),0));
64
+ }
65
+ })();
66
+ </script>