keywright-server 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,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 <host> -r "why" -- <command></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 <gateway></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 <agent token></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>
|