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,433 @@
1
+ """Request lifecycle: create -> notify -> (human decides) -> grant/policy -> waiters wake up."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hmac
8
+ import logging
9
+ from datetime import timedelta
10
+ from typing import Any
11
+
12
+ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
13
+ from keywright.hazards import blocks
14
+ from keywright.patterns import overlaps, resource_of
15
+ from sqlalchemy import select, update
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from . import actions, audit, events, llm
19
+ from .access import (
20
+ binds_exactly,
21
+ hazard_level,
22
+ operation_constraints,
23
+ operation_matches,
24
+ operations,
25
+ path_scoped,
26
+ target_of,
27
+ )
28
+ from .config import settings
29
+ from .models import AccessRequest, Agent, Grant, Policy, Resource, now
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ TTL_PRESETS = {"ttl15": 900, "ttl30": 1800, "ttl": 3600}
34
+ SCOPES = ("once", "session", "ttl15", "path15", "ttl30", "ttl", "always", "deny", "deny_always")
35
+ PHONE_SCOPES = ("once", "session", "ttl15", "path15", "ttl30", "ttl", "deny")
36
+ SCOPE_LABELS = {"once": "Allow once", "session": "This session", "ttl15": "15 minutes", "path15": "This path, 15 min",
37
+ "ttl30": "30 minutes", "ttl": "1 hour", "always": "Always allow",
38
+ "deny": "Deny", "deny_always": "Always deny"}
39
+
40
+
41
+ def decision_label(req: AccessRequest) -> str:
42
+ if req.decision_scope == "ttl" and req.decision_options:
43
+ minutes = req.decision_options["ttl_s"] // 60
44
+ return "1 hour" if minutes == 60 else f"{minutes} minute{'' if minutes == 1 else 's'}"
45
+ return SCOPE_LABELS.get(req.decision_scope, req.decision_scope or "Pending")
46
+
47
+
48
+ _waiters: dict[str, asyncio.Event] = {}
49
+ _waiter_counts: dict[str, int] = {}
50
+ _serializer: URLSafeTimedSerializer | None = None
51
+
52
+
53
+ def _ser() -> URLSafeTimedSerializer:
54
+ global _serializer
55
+ if _serializer is None:
56
+ _serializer = URLSafeTimedSerializer(settings.secret_key(), salt="decision")
57
+ return _serializer
58
+
59
+
60
+ def decision_token(request_id: str, scope: str) -> str:
61
+ if scope not in PHONE_SCOPES:
62
+ raise ValueError("Permanent approval requires the admin UI")
63
+ return _ser().dumps({"r": request_id, "s": scope})
64
+
65
+
66
+ def parse_decision_token(token: str, max_age: int | None = None) -> tuple[str, str] | None:
67
+ try:
68
+ d = _ser().loads(token, max_age=max_age or settings.request_timeout_s)
69
+ except (BadSignature, SignatureExpired):
70
+ return None
71
+ if not isinstance(d, dict) or not isinstance(d.get("r"), str) or d.get("s") not in PHONE_SCOPES:
72
+ return None
73
+ return d["r"], d["s"]
74
+
75
+
76
+ def notification_action(request_id: str, scope: str) -> str:
77
+ """Compact authenticated phone action; the 128-bit MAC cannot be derived from a request ID."""
78
+ if scope not in PHONE_SCOPES:
79
+ raise ValueError("Permanent approval requires the admin UI")
80
+ return _action("KW", request_id, scope)
81
+
82
+
83
+ def _action(prefix: str, request_id: str, scope: str) -> str:
84
+ return actions.sign(f"{prefix}_{scope.upper()}_{request_id}")
85
+
86
+
87
+ def parse_notification_action(action: str) -> tuple[str, str] | None:
88
+ """Accepts KW_ actions and, for one release, GK_ actions from notifications sent before the rename."""
89
+ try:
90
+ body, _ = action.rsplit(".", 1)
91
+ prefix, scope, rid = body.split("_", 2)
92
+ if prefix not in ("KW", "GK") or scope.lower() not in PHONE_SCOPES:
93
+ return None
94
+ if hmac.compare_digest(action, _action(prefix, rid, scope.lower())):
95
+ return rid, scope.lower()
96
+ except (ValueError, TypeError):
97
+ pass
98
+ return None
99
+
100
+
101
+ NO_SESSION_NOTE = ("No session id on this connection, so 'This session' cannot be granted. Send an "
102
+ "X-Keywright-Session header (one value per process) to enable it; until then the human "
103
+ "can choose Allow once or timed access (15 minutes, 30 minutes, or 1 hour).")
104
+
105
+
106
+ HAZARD_SCOPES = ("once", "deny", "deny_always")
107
+ # An LLM lease is a virtual key with a lifetime and a spend ceiling. "Once" has no lifetime to give
108
+ # it and "always" would be a key nobody minted; both wait for the LLM proxy, which meters calls.
109
+ LLM_SCOPES = ("session", "ttl15", "ttl30", "ttl", "deny", "deny_always")
110
+
111
+
112
+ def llm_only(patterns: list[str]) -> bool:
113
+ return bool(patterns) and all(p.split("/", 1)[0] == "llm" for p in patterns)
114
+
115
+
116
+ def offered_scopes(req: AccessRequest, scopes: tuple[str, ...] = PHONE_SCOPES) -> tuple[str, ...]:
117
+ """Scopes a human can pick for this request. A request made on a connection with no session
118
+ id cannot be granted 'this session': there is nothing to bind the grant to, and an agent-wide
119
+ grant would outlive the conversation that asked for it. A request flagged destructive can only
120
+ be allowed once: nothing timed, nothing standing. An LLM lease is always timed."""
121
+ if blocks(hazard_level(req.detail)):
122
+ scopes = tuple(s for s in scopes if s in HAZARD_SCOPES)
123
+ if llm_only(list(req.patterns)):
124
+ scopes = tuple(s for s in scopes if s in LLM_SCOPES)
125
+ scopes = tuple(s for s in scopes if s != "path15" or path_scoped(req.detail))
126
+ return tuple(s for s in scopes if s != "session" or req.session_id)
127
+
128
+
129
+ def decision_links(request_id: str, scopes: tuple[str, ...] = PHONE_SCOPES) -> dict[str, str]:
130
+ base = public_url()
131
+ return {s: f"{base}/d/{decision_token(request_id, s)}" for s in scopes}
132
+
133
+
134
+ def public_url() -> str:
135
+ from .settings_store import cached_public_url
136
+
137
+ return (cached_public_url() or settings.public_url or f"http://localhost:{settings.port}").rstrip("/")
138
+
139
+
140
+ async def find_pending(db: AsyncSession, agent: Agent, patterns: list[str],
141
+ session_id: str | None = None, detail: dict | None = None) -> AccessRequest | None:
142
+ """Reuse an identical pending request instead of spamming the phone."""
143
+ q = select(AccessRequest).where(
144
+ AccessRequest.agent_id == agent.id, AccessRequest.status == "pending",
145
+ AccessRequest.expires_at > now(), AccessRequest.session_id == session_id
146
+ ).order_by(AccessRequest.created_at.desc())
147
+ for r in (await db.execute(q)).scalars().all():
148
+ if list(r.patterns) == list(patterns) and r.detail == (detail or {}):
149
+ return r
150
+ return None
151
+
152
+
153
+ async def recently_denied(db: AsyncSession, agent: Agent, patterns: list[str], within_s: int = 600) -> AccessRequest | None:
154
+ """The cooldown keys on patterns alone: a reworded command or a switch to access_request must
155
+ not put a denied operation straight back in front of the human."""
156
+ cutoff = now() - timedelta(seconds=within_s)
157
+ q = select(AccessRequest).where(
158
+ AccessRequest.agent_id == agent.id, AccessRequest.status == "denied", AccessRequest.decided_at >= cutoff
159
+ )
160
+ for r in (await db.execute(q)).scalars().all():
161
+ if set(r.patterns) >= set(patterns):
162
+ return r
163
+ return None
164
+
165
+
166
+ async def create(
167
+ db: AsyncSession,
168
+ agent: Agent,
169
+ session_id: str | None,
170
+ patterns: list[str],
171
+ reason: str,
172
+ detail: dict[str, Any] | None = None,
173
+ *,
174
+ semantic: dict[str, Any] | None = None,
175
+ notify: bool = True,
176
+ ) -> AccessRequest:
177
+ """`notify=False` is for a request that is decided in the same breath (Jev trust mode): the
178
+ phone would otherwise get a prompt and its cancellation a moment apart."""
179
+ req = AccessRequest(
180
+ agent_id=agent.id,
181
+ session_id=session_id,
182
+ patterns=list(patterns),
183
+ reason=reason[:2000],
184
+ detail=detail or {},
185
+ semantic=semantic,
186
+ expires_at=now() + timedelta(seconds=settings.request_timeout_s),
187
+ )
188
+ db.add(req)
189
+ await db.commit()
190
+ await db.refresh(req)
191
+ await audit.record(db, tool="access.request", decision="pending", agent=agent, session_id=session_id,
192
+ patterns=patterns, detail={"request_id": req.id, "reason": reason})
193
+ from .notify.manager import manager
194
+
195
+ if notify:
196
+ await manager.notify_request(req, agent)
197
+ events.publish("request.created", id=req.id)
198
+ return req
199
+
200
+
201
+ async def wait(request_id: str, timeout_s: float) -> None:
202
+ # Events accelerate local decisions; polling also observes another worker's decisions.
203
+ # No events are allocated for requests nobody is waiting on.
204
+ ev = _waiters.setdefault(request_id, asyncio.Event())
205
+ _waiter_counts[request_id] = _waiter_counts.get(request_id, 0) + 1
206
+ deadline = asyncio.get_running_loop().time() + max(0, timeout_s)
207
+ from .db import session
208
+
209
+ try:
210
+ while True:
211
+ async with session() as db:
212
+ req = await db.get(AccessRequest, request_id)
213
+ if req is None or req.status != "pending" or (req.expires_at and req.expires_at <= now()):
214
+ return
215
+ remaining = deadline - asyncio.get_running_loop().time()
216
+ if remaining <= 0:
217
+ return
218
+ try:
219
+ await asyncio.wait_for(ev.wait(), timeout=min(remaining, 1))
220
+ return
221
+ except TimeoutError:
222
+ pass
223
+ finally:
224
+ _waiter_counts[request_id] -= 1
225
+ if not _waiter_counts[request_id]:
226
+ _waiter_counts.pop(request_id)
227
+ _waiters.pop(request_id, None)
228
+
229
+
230
+ async def decide(
231
+ db: AsyncSession,
232
+ request_id: str,
233
+ scope: str,
234
+ *,
235
+ via: str,
236
+ deny_reason: str = "",
237
+ ttl_s: int = 3600,
238
+ extent: str | None = None,
239
+ ) -> AccessRequest:
240
+ if scope not in SCOPES:
241
+ raise ValueError(f"unknown scope {scope!r}")
242
+ # "This path, 15 min": the listed pattern (host and path) for 15 minutes, whatever the contents.
243
+ # The one phone button that broadens beyond the exact operation, offered only where the
244
+ # pattern is as narrow as a file path.
245
+ path_grant = scope == "path15"
246
+ if path_grant:
247
+ scope = "ttl15"
248
+ if scope in ("always", "deny_always") and via != "ui":
249
+ raise ValueError("Permanent approval requires the admin UI")
250
+ if extent not in (None, "exact", "patterns"):
251
+ raise ValueError("Choose exact operation or permission patterns")
252
+ if via != "ui" and extent is not None:
253
+ raise ValueError("Changing operation scope requires the admin UI")
254
+ if scope in TTL_PRESETS:
255
+ ttl_s = TTL_PRESETS[scope] if scope != "ttl" else ttl_s
256
+ if type(ttl_s) is not int or not 60 <= ttl_s <= 3600:
257
+ raise ValueError("Temporary access must last between 1 minute and 1 hour")
258
+ scope = "ttl"
259
+ req = await db.get(AccessRequest, request_id)
260
+ if req is None:
261
+ raise LookupError("no such request")
262
+ if req.status != "pending":
263
+ return req
264
+ if path_grant:
265
+ if not path_scoped(req.detail):
266
+ raise ValueError("Path access is only offered for a single file write")
267
+ extent = "patterns"
268
+ extent = extent or ("exact" if binds_exactly(req.detail) else "patterns")
269
+ if extent == "exact" and not binds_exactly(req.detail):
270
+ raise ValueError("This request specifies permission patterns, not an exact operation")
271
+ if blocks(hazard_level(req.detail)):
272
+ if scope not in HAZARD_SCOPES:
273
+ raise ValueError("Flagged as destructive: it can be allowed once or denied, never for a duration")
274
+ if extent != "exact":
275
+ raise ValueError("Flagged as destructive: approval binds to the exact operation shown")
276
+ # One (patterns, constraints) pair per operation; a pattern request is one pair with none.
277
+ bound = [(pats if pats is not None else list(req.patterns), operation_constraints(op, extent), op)
278
+ for pats, op in operations(req.detail)] or [(list(req.patterns), None, None)]
279
+ if scope == "session" and not req.session_id:
280
+ raise ValueError("This request has no session id; choose Allow once or 1 hour instead")
281
+ if scope not in LLM_SCOPES and any(p.split("/", 1)[0] == "llm" for p in req.patterns):
282
+ raise ValueError("LLM access is a timed lease: choose this session, 15 or 30 minutes, or 1 hour")
283
+ if req.expires_at and req.expires_at <= now():
284
+ await db.execute(update(AccessRequest).where(
285
+ AccessRequest.id == request_id, AccessRequest.status == "pending",
286
+ ).values(status="expired").execution_options(synchronize_session=False))
287
+ await db.commit()
288
+ await db.refresh(req)
289
+ _wake(req.id)
290
+ events.publish("request.decided", id=req.id, status=req.status)
291
+ return req
292
+
293
+ if scope not in ("deny", "deny_always"):
294
+ identity = await db.get(Agent, req.agent_id, populate_existing=True)
295
+ if identity is None or identity.disabled:
296
+ raise ValueError("Agent is disabled or deleted")
297
+ resources = {r.path_prefix: r for r in (await db.execute(select(Resource))).scalars().all()}
298
+ policies = (await db.execute(select(Policy).where(
299
+ (Policy.agent_id == req.agent_id) | Policy.agent_id.is_(None)))).scalars().all()
300
+ for pats, _, op in bound:
301
+ for pattern in pats:
302
+ rp = resource_of(pattern)
303
+ res = resources.get("/".join(rp)) if rp else None
304
+ if res is None or (res.default_mode == "locked" and scope != "always"):
305
+ raise ValueError("Resource is missing or locked; update its rules in the admin UI")
306
+ if any(p.effect == "deny" and overlaps(p.pattern, pattern)
307
+ and operation_matches(p.constraints, op or target_of(res))
308
+ for p in policies):
309
+ raise ValueError("A standing deny rule now covers this request")
310
+ decided_at = now()
311
+ changed = await db.execute(update(AccessRequest).where(
312
+ AccessRequest.id == request_id, AccessRequest.status == "pending",
313
+ (AccessRequest.expires_at.is_(None)) | (AccessRequest.expires_at > decided_at),
314
+ ).values(status="denied" if scope in ("deny", "deny_always") else "approved", decided_at=decided_at,
315
+ decision_scope=scope, decided_via=via,
316
+ decision_options={"extent": extent, "ttl_s": ttl_s if scope == "ttl" else None}
317
+ ).execution_options(synchronize_session=False))
318
+ if changed.rowcount != 1:
319
+ await db.rollback()
320
+ await db.refresh(req)
321
+ return req
322
+ await db.refresh(req)
323
+ if scope in ("deny", "deny_always"):
324
+ req.status = "denied"
325
+ req.deny_reason = deny_reason[:500]
326
+ if scope == "deny_always":
327
+ for pats, constraints, _ in bound:
328
+ for p in pats:
329
+ db.add(Policy(agent_id=req.agent_id, pattern=p, effect="deny", constraints=constraints,
330
+ request_id=req.id,
331
+ created_by=via, note=f"from request {req.id}: {req.reason[:120]}"))
332
+ else:
333
+ req.status = "approved"
334
+ new_grants: list[Grant] = []
335
+ for pats, constraints, _ in bound:
336
+ for p in pats:
337
+ if scope == "always":
338
+ db.add(Policy(agent_id=req.agent_id, pattern=p, effect="allow", constraints=constraints,
339
+ request_id=req.id, created_by=via,
340
+ note=f"from request {req.id}: {req.reason[:120]}"))
341
+ continue
342
+ g = Grant(
343
+ agent_id=req.agent_id,
344
+ pattern=p,
345
+ constraints=constraints,
346
+ scope=scope,
347
+ session_id=req.session_id if scope == "session" else None,
348
+ expires_at=(decided_at + timedelta(seconds=ttl_s)) if scope == "ttl"
349
+ else (decided_at + timedelta(seconds=settings.session_grant_ttl_s)) if scope == "session"
350
+ else None,
351
+ uses_remaining=1 if scope == "once" else None,
352
+ request_id=req.id,
353
+ )
354
+ db.add(g)
355
+ new_grants.append(g)
356
+ await db.flush()
357
+ # The lease and its key are one decision: a gateway that cannot mint (unreachable, or no
358
+ # database to enforce budgets against) leaves the request pending for the human to see.
359
+ try:
360
+ keys = await llm.mint_for_grants(db, identity, new_grants, request_id=req.id, reason=req.reason)
361
+ except llm.GatewayError as e:
362
+ await db.rollback() # the request stays pending; whoever is waiting keeps waiting
363
+ raise ValueError(f"Not approved. {e}") from e
364
+ for k in keys:
365
+ await audit.record(db, tool="llm.key", decision="minted", agent=identity, session_id=req.session_id,
366
+ patterns=[g.pattern for g in new_grants if g.id in k.grant_ids],
367
+ detail={"request_id": req.id, "key_alias": k.key_alias, "models": k.models,
368
+ "limits": k.limits, "expires_at": k.expires_at.isoformat()}, commit=False)
369
+ await db.commit()
370
+ await audit.record(db, tool="access.decide", decision=req.status, agent=req.agent, session_id=req.session_id,
371
+ patterns=list(req.patterns), detail={"request_id": req.id, "scope": scope, "via": via,
372
+ "deny_reason": deny_reason,
373
+ **req.decision_options})
374
+ _wake(req.id)
375
+ from .notify.manager import manager
376
+
377
+ await manager.request_decided(req)
378
+ events.publish("request.decided", id=req.id, status=req.status, via=via, label=decision_label(req))
379
+ return req
380
+
381
+
382
+ def _wake(request_id: str) -> None:
383
+ ev = _waiters.pop(request_id, None)
384
+ if ev:
385
+ ev.set()
386
+
387
+
388
+ async def expire_stale(db: AsyncSession) -> int:
389
+ expired = list((await db.execute(update(AccessRequest).where(
390
+ AccessRequest.status == "pending", AccessRequest.expires_at <= now(),
391
+ ).values(status="expired").returning(AccessRequest.id))).scalars())
392
+ await db.commit()
393
+ if expired:
394
+ # Take the dead buttons off the phone: an expired request can no longer be decided.
395
+ from .notify.manager import manager
396
+
397
+ for req in (await db.execute(select(AccessRequest).where(AccessRequest.id.in_(expired)))).scalars():
398
+ with contextlib.suppress(Exception):
399
+ await manager.request_decided(req)
400
+ for rid in expired:
401
+ _wake(rid)
402
+ events.publish("request.decided", id=rid, status="expired")
403
+ return len(expired)
404
+
405
+
406
+ async def revoke_grant(db: AsyncSession, grant_id: str, via: str = "ui") -> Grant | None:
407
+ g = await db.get(Grant, grant_id)
408
+ if g and g.revoked_at is None:
409
+ g.revoked_at = now()
410
+ await db.commit()
411
+ await audit.record(db, tool="grant.revoke", decision="revoked", patterns=[g.pattern],
412
+ detail={"grant_id": g.id, "agent_id": g.agent_id, "via": via})
413
+ if g.pattern.startswith("llm/"):
414
+ await llm.revoke_for_grants(db, [g.id], via=via)
415
+ await db.commit()
416
+ return g
417
+
418
+
419
+ async def revoke_request_grants(db: AsyncSession, request_id: str) -> int:
420
+ """Revoke all remaining grants from one approval in a single transaction."""
421
+ grants = (await db.execute(select(Grant).where(
422
+ Grant.request_id == request_id, Grant.revoked_at.is_(None)))).scalars().all()
423
+ at = now()
424
+ for g in grants:
425
+ g.revoked_at = at
426
+ await audit.record(db, tool="grant.revoke", decision="revoked", patterns=[g.pattern],
427
+ detail={"grant_id": g.id, "agent_id": g.agent_id,
428
+ "request_id": request_id, "via": "ui"}, commit=False)
429
+ await db.commit()
430
+ if any(g.pattern.startswith("llm/") for g in grants):
431
+ await llm.revoke_for_grants(db, [g.id for g in grants], via="ui")
432
+ await db.commit()
433
+ return len(grants)
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+ from typing import Any
5
+
6
+ # Set per MCP call from the User-Agent header when it names a Keywright client; recorded on every
7
+ # audit row of that call. Kept out of the operation dict so exact-bound grants never see it.
8
+ client_agent: ContextVar[str | None] = ContextVar("client_agent", default=None)
9
+
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+
12
+ from .models import Agent, AuditEvent
13
+
14
+
15
+ async def record(
16
+ db: AsyncSession,
17
+ *,
18
+ tool: str,
19
+ decision: str,
20
+ agent: Agent | None = None,
21
+ session_id: str | None = None,
22
+ patterns: list[str] | None = None,
23
+ detail: dict[str, Any] | None = None,
24
+ exit_code: int | None = None,
25
+ duration_ms: int | None = None,
26
+ commit: bool = True,
27
+ ) -> AuditEvent:
28
+ ev = AuditEvent(
29
+ agent_id=agent.id if agent else None,
30
+ agent_name=agent.name if agent else "",
31
+ session_id=session_id,
32
+ tool=tool,
33
+ patterns=list(patterns or []),
34
+ decision=decision,
35
+ detail=_scrub({**(detail or {}), **({"client": client_agent.get()} if client_agent.get() else {})}),
36
+ exit_code=exit_code,
37
+ duration_ms=duration_ms,
38
+ )
39
+ db.add(ev)
40
+ if commit:
41
+ await db.commit()
42
+ return ev
43
+
44
+
45
+ def _scrub(value: Any) -> Any:
46
+ """Redact nested credential fields before persistence, notifications, or display."""
47
+ if isinstance(value, dict):
48
+ return {
49
+ k: "***" if any(s in k.lower() for s in (
50
+ "token", "secret", "password", "authorization", "api_key", "apikey", "private_key", "credential",
51
+ )) else _scrub(v)
52
+ for k, v in value.items()
53
+ }
54
+ if isinstance(value, list):
55
+ return [_scrub(item) for item in value]
56
+ if isinstance(value, str) and len(value) > 4000:
57
+ return value[:4000] + f"... [{len(value) - 4000} more]"
58
+ return value
@@ -0,0 +1,87 @@
1
+ """Named sets of patterns an MCP resource owner defines, e.g. read-only / control / destructive.
2
+
3
+ Stored in the resource config as {"bundles": {name: ["tools/ha_get_*", ...]}} with entries relative
4
+ to the resource. An agent asks for `mcp/<server>/bundle/<name>` and Keywright expands it before
5
+ evaluation; the human sees the request grouped by bundle. Bundles are a request-time convenience:
6
+ rules and grants are always on the expanded patterns.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from keywright.patterns import validate
13
+
14
+ NAME = re.compile(r"^[A-Za-z0-9_.-]{1,40}$")
15
+ MAX_BUNDLES = 32
16
+ MAX_ENTRIES = 64
17
+
18
+ # Verb heuristics for "suggest bundles" from an upstream tool list. Generic, not server-specific.
19
+ _VERBS = {
20
+ "read-only": ("get", "list", "search", "find", "read", "describe", "show", "query", "check", "status", "history",
21
+ "render", "export", "fetch", "lookup"),
22
+ "control": ("call", "run", "execute", "trigger", "manage", "start", "stop", "toggle", "turn", "send", "notify",
23
+ "play", "press", "enable", "disable"),
24
+ "destructive": ("remove", "delete", "restart", "reboot", "reset", "wipe", "purge", "uninstall", "shutdown"),
25
+ "config-write": ("set", "config", "create", "update", "write", "add", "edit", "rename", "install", "upload",
26
+ "import", "apply", "assign", "patch", "put", "save"),
27
+ }
28
+
29
+
30
+ def parse(text: str, resource_name: str) -> dict[str, list[str]]:
31
+ """`name: entry, entry` per line -> {name: [entry, ...]}; entries are validated as full paths."""
32
+ out: dict[str, list[str]] = {}
33
+ for raw in text.splitlines():
34
+ line = raw.strip()
35
+ if not line or line.startswith("#"):
36
+ continue
37
+ if ":" not in line:
38
+ raise ValueError(f"expected 'name: pattern, pattern' but got {line!r}")
39
+ name, rest = (s.strip() for s in line.split(":", 1))
40
+ if not NAME.match(name) or name in out:
41
+ raise ValueError(f"bad or duplicate bundle name {name!r}")
42
+ entries: list[str] = []
43
+ for e in (x.strip() for x in rest.replace("\t", ",").split(",")):
44
+ if not e:
45
+ continue
46
+ e = e.strip("/")
47
+ e = e.removeprefix(f"mcp/{resource_name}/")
48
+ full = validate(f"mcp/{resource_name}/{e}")
49
+ rel = full[len(f"mcp/{resource_name}/"):]
50
+ if not rel.startswith("tools/") or len(rel.split("/")) < 2:
51
+ raise ValueError(f"bundle entry {e!r} must be tools/<tool>[/<key>=<value>]")
52
+ if rel not in entries:
53
+ entries.append(rel)
54
+ if not entries:
55
+ raise ValueError(f"bundle {name!r} is empty")
56
+ if len(entries) > MAX_ENTRIES:
57
+ raise ValueError(f"bundle {name!r} has more than {MAX_ENTRIES} entries")
58
+ out[name] = entries
59
+ if len(out) > MAX_BUNDLES:
60
+ raise ValueError(f"more than {MAX_BUNDLES} bundles")
61
+ return out
62
+
63
+
64
+ def format_text(bundles: dict[str, list[str]] | None) -> str:
65
+ return "\n".join(f"{name}: {', '.join(entries)}" for name, entries in (bundles or {}).items())
66
+
67
+
68
+ def expand(resource_name: str, bundles: dict[str, list[str]] | None, name: str) -> list[str] | None:
69
+ entries = (bundles or {}).get(name)
70
+ if entries is None:
71
+ return None
72
+ return [f"mcp/{resource_name}/{e}" for e in entries]
73
+
74
+
75
+ def suggest(tool_names: list[str]) -> dict[str, list[str]]:
76
+ """Group upstream tools by the verb in their name. A starting point for the owner to edit."""
77
+ out: dict[str, list[str]] = {}
78
+ for tool in sorted(set(tool_names)):
79
+ words = [w for w in re.split(r"[_\-.]+|(?<=[a-z])(?=[A-Z])", tool) if w]
80
+ lowered = [w.lower() for w in words]
81
+ chosen = None
82
+ for bucket, verbs in _VERBS.items():
83
+ if any(w in verbs or any(w.startswith(v) for v in verbs if len(v) >= 4) for w in lowered):
84
+ chosen = bucket
85
+ break
86
+ out.setdefault(chosen or "other", []).append(f"tools/{tool}")
87
+ return {k: v for k, v in out.items() if v}
@@ -0,0 +1,102 @@
1
+ """Process configuration. Everything else (resources, agents, notifiers) lives in the database
2
+ and is managed from the admin UI, so nothing device-specific is ever hard-coded here."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import os
8
+ import secrets
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ log = logging.getLogger("keywright.config")
13
+ _warned: set[str] = set()
14
+
15
+
16
+ def env(name: str, default: str | None = None) -> str | None:
17
+ """KW_<name>, falling back to the pre-rename GK_<name> for one release."""
18
+ value = os.environ.get(f"KW_{name}")
19
+ if value is not None:
20
+ return value
21
+ legacy = os.environ.get(f"GK_{name}")
22
+ if legacy is not None:
23
+ if name not in _warned:
24
+ _warned.add(name)
25
+ log.warning("GK_%s is deprecated; set KW_%s instead", name, name)
26
+ return legacy
27
+ return default
28
+
29
+
30
+ @dataclass
31
+ class Settings:
32
+ data_dir: Path = field(default_factory=lambda: Path(env("DATA_DIR", "./data")).resolve())
33
+ host: str = field(default_factory=lambda: env("HOST", "0.0.0.0"))
34
+ port: int = field(default_factory=lambda: int(env("PORT", "8700")))
35
+ # Used to build links in notifications. Configurable in the UI too (settings.public_url wins).
36
+ public_url: str = field(default_factory=lambda: env("PUBLIC_URL", ""))
37
+ # Reverse proxies whose X-Forwarded-Proto/Host/For headers are trusted (comma-separated IPs,
38
+ # CIDRs or "*"). Empty keeps uvicorn's loopback-only default. Behind TLS-terminating proxies
39
+ # this must name the proxy, or the app sees itself as http:// and rejects browser form posts.
40
+ trusted_proxies: str = field(default_factory=lambda: env("TRUSTED_PROXIES", ""))
41
+ admin_password: str | None = field(default_factory=lambda: env("ADMIN_PASSWORD"))
42
+ auth_mode: str = field(default_factory=lambda: env("AUTH_MODE", "password"))
43
+ oidc_issuer: str = field(default_factory=lambda: env("OIDC_ISSUER", ""))
44
+ oidc_client_id: str = field(default_factory=lambda: env("OIDC_CLIENT_ID", ""))
45
+ oidc_client_secret: str = field(default_factory=lambda: env("OIDC_CLIENT_SECRET", ""))
46
+ oidc_admin_group: str = field(default_factory=lambda: env("OIDC_ADMIN_GROUP", ""))
47
+ oidc_session_ttl_s: int = field(default_factory=lambda: int(env("OIDC_SESSION_TTL", "3600")))
48
+ request_timeout_s: int = field(default_factory=lambda: int(env("REQUEST_TIMEOUT", "900")))
49
+ # How long an unanswered "the agent needs you" nudge stays on the dashboard and the phone.
50
+ nudge_ttl_s: int = field(default_factory=lambda: int(env("NUDGE_TTL", "43200")))
51
+ # Upper bound on "this session" grants; the session itself usually ends sooner.
52
+ session_grant_ttl_s: int = field(default_factory=lambda: int(env("SESSION_GRANT_TTL", "3600")))
53
+ # How long a proxied LLM call waits for the human before answering 403 approval_required.
54
+ llm_wait_s: int = field(default_factory=lambda: int(env("LLM_WAIT", "60")))
55
+ log_level: str = field(default_factory=lambda: env("LOG_LEVEL", "INFO"))
56
+ # TypeSafe key for Jev's second opinion on operations that prompt the human (semantic.py).
57
+ # Absent, the feature is off and nothing leaves the host.
58
+ typesafe_api_key: str | None = field(default_factory=lambda: env("TYPESAFE_API_KEY"))
59
+
60
+ @property
61
+ def db_path(self) -> Path:
62
+ new, old = self.data_dir / "keywright.db", self.data_dir / "gatekeeper.db"
63
+ if old.exists() and not new.exists():
64
+ for suffix in ("", "-wal", "-shm"):
65
+ if (old.parent / (old.name + suffix)).exists():
66
+ (old.parent / (old.name + suffix)).rename(new.parent / (new.name + suffix))
67
+ log.warning("renamed %s to %s", old, new)
68
+ return new
69
+
70
+ @property
71
+ def db_url(self) -> str:
72
+ return env("DB_URL") or f"sqlite+aiosqlite:///{self.db_path}"
73
+
74
+ @property
75
+ def keys_dir(self) -> Path:
76
+ return self.data_dir / "keys"
77
+
78
+ def secret_key(self) -> bytes:
79
+ """Signing key for sessions and decision tokens. Generated once, kept in the data dir."""
80
+ configured = env("SECRET_KEY")
81
+ if configured:
82
+ return configured.encode()
83
+ p = self.data_dir / "secret.key"
84
+ if p.exists():
85
+ return p.read_bytes().strip()
86
+ self.data_dir.mkdir(parents=True, exist_ok=True)
87
+ key = secrets.token_urlsafe(48).encode()
88
+ p.write_bytes(key)
89
+ p.chmod(0o600)
90
+ return key
91
+
92
+ def ensure_dirs(self) -> None:
93
+ self.data_dir.mkdir(parents=True, exist_ok=True)
94
+ self.keys_dir.mkdir(parents=True, exist_ok=True)
95
+ try:
96
+ os.chmod(self.data_dir, 0o700)
97
+ os.chmod(self.keys_dir, 0o700)
98
+ except OSError:
99
+ pass
100
+
101
+
102
+ settings = Settings()