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,312 @@
1
+ """Inbox cache and owner-configured mail automations; mailbox calls retain broker policy checks.
2
+
3
+ The UI worker is opt-in and independent of the legacy YAML router. Disable overlapping
4
+ legacy rules before enabling it. A first sync only indexes mail; existing mail is processed
5
+ only by an explicit Run rules action. Delivery receipts survive retries and process restarts.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ from email.utils import parseaddr
15
+ from fnmatch import fnmatchcase
16
+ from urllib.parse import urlsplit
17
+
18
+ import httpx
19
+ from sqlalchemy import select
20
+
21
+ from . import audit, settings_store
22
+ from .db import session
23
+ from .models import AccessRequest, Agent, MailMessage, MailRule, Resource, now
24
+
25
+ log = logging.getLogger(__name__)
26
+ MAX_PDF = 5 * 1024 * 1024 # upstream's 8 MiB serialized result must also fit base64
27
+ STATUS = {"unprocessed": "Unprocessed", "queued": "Queued", "running": "Processing",
28
+ "pending": "Waiting for approval", "done": "Completed", "failed": "Failed"}
29
+ lock = asyncio.Lock()
30
+
31
+
32
+ class MailError(Exception):
33
+ pass
34
+
35
+
36
+ class Pending(MailError):
37
+ def __init__(self, request_id):
38
+ self.request_id = request_id
39
+ super().__init__("Waiting for Keywright approval")
40
+
41
+
42
+ def source(cfg):
43
+ return hashlib.sha256(json.dumps([cfg.get(k) for k in ("resource", "account", "mailbox")]).encode()).hexdigest()
44
+
45
+
46
+ def budget_url(value):
47
+ url = urlsplit(value.strip())
48
+ if (url.scheme not in {"http", "https"} or not url.hostname or url.username or url.password
49
+ or url.query or url.fragment or url.path.rstrip('/') not in {'', '/api/integrations/documents'}):
50
+ raise MailError("Enter the budget server URL, without credentials, a query, or an ingress path.")
51
+ return f"{url.scheme}://{url.netloc}/api/integrations/documents"
52
+
53
+
54
+ async def config(db):
55
+ cfg = await settings_store.get(db, "mail")
56
+ return {"account": "agents", "mailbox": "INBOX", "enabled": False, **cfg}
57
+
58
+
59
+ def payload(result):
60
+ data = getattr(result, "structured_content", None)
61
+ if isinstance(data, dict):
62
+ return data
63
+ for block in result.content:
64
+ if getattr(block, "type", None) == "text":
65
+ try:
66
+ data = json.loads(block.text)
67
+ if isinstance(data, dict):
68
+ return data
69
+ except (ValueError, TypeError):
70
+ pass
71
+ return {}
72
+
73
+
74
+ async def call(db, cfg, tool, args):
75
+ from .mcp_server import _proxy_call
76
+ agent = await db.get(Agent, cfg.get("agent")) if cfg.get("agent") else None
77
+ resource = await db.get(Resource, cfg.get("resource")) if cfg.get("resource") else None
78
+ if not agent or agent.disabled or not resource or resource.kind != "mcp":
79
+ raise MailError("Choose an enabled agent and an email resource in Mail settings.")
80
+ result = await _proxy_call(db, agent, "mail-inbox", resource.name, tool,
81
+ {**args, "_reason": "Mailbox UI and saved mail rules", "_wait_seconds": 0})
82
+ if result.is_error:
83
+ data = payload(result)
84
+ if data.get("error") == "approval_required" and data.get("request_id"):
85
+ raise Pending(data["request_id"])
86
+ # Do not surface upstream errors that might contain credentials or message contents.
87
+ raise MailError(f"Email operation {tool} failed. Check Keywright’s audit log and email service settings.")
88
+ return result
89
+
90
+
91
+ def mail_args(cfg):
92
+ return {"account_name": cfg["account"], "mailbox": cfg["mailbox"]}
93
+
94
+
95
+ def matches(rule, message):
96
+ return ((not rule.sender or fnmatchcase(parseaddr(message.sender)[1].lower(), rule.sender.lower()))
97
+ and (not rule.subject or rule.subject.lower() in message.subject.lower()))
98
+
99
+
100
+ def snapshot(rule, cfg):
101
+ return {"rule_id": rule.id, "name": rule.name, "action": rule.action, "target": rule.target,
102
+ "destination": cfg.get("budget_url", "") if rule.action == "pdf" else ""}
103
+
104
+
105
+ async def rules(db):
106
+ return list((await db.scalars(select(MailRule).order_by(MailRule.position, MailRule.created_at))).all())
107
+
108
+
109
+ async def queue(db, cfg, message):
110
+ if message.status in {"done", "running", "pending", "queued"}:
111
+ return
112
+ selected = next((r for r in await rules(db) if r.enabled and matches(r, message)), None)
113
+ if not selected:
114
+ message.note = "No enabled rule matched."
115
+ await db.commit()
116
+ return
117
+ message.plan = snapshot(selected, cfg)
118
+ message.status, message.note, message.request_id = "queued", "", None
119
+ message.updated_at = now()
120
+ await db.commit()
121
+
122
+
123
+ async def sync(db, cfg):
124
+ if not cfg.get("resource"):
125
+ raise MailError("Connect the mailbox in Mail settings first.")
126
+ identity = source(cfg)
127
+ initial = cfg.get("indexed_source") != identity
128
+ existing = {m.id: m for m in (await db.scalars(select(MailMessage).where(MailMessage.source == identity))).all()}
129
+ seen = set()
130
+ active_rules = [r for r in await rules(db) if r.enabled]
131
+ for page in range(1, 101): # upstream caps metadata candidates at 10,000
132
+ data = payload(await call(db, cfg, "list_emails_metadata", {
133
+ **mail_args(cfg), "page": page, "page_size": 100, "order": "desc"}))
134
+ if not isinstance(data.get("emails"), list):
135
+ raise MailError("Email service returned an invalid message list.")
136
+ for meta in data["emails"]:
137
+ uid = str(meta["email_id"])
138
+ # Include immutable headers as well as Message-ID: some senders omit/reuse that header.
139
+ fingerprint = [identity, uid, *(str(meta.get(k) or '') for k in ('message_id', 'sender', 'subject', 'date'))]
140
+ key = hashlib.sha256(json.dumps(fingerprint).encode()).hexdigest()
141
+ seen.add(key)
142
+ message = existing.get(key)
143
+ if not message:
144
+ message = MailMessage(id=key, source=identity, uid=uid)
145
+ db.add(message)
146
+ existing[key] = message
147
+ message.message_id = str(meta.get("message_id") or "")
148
+ for field in ("sender", "subject", "date"):
149
+ setattr(message, field, str(meta.get(field) or "")[:4000])
150
+ message.recipients = list(meta.get("recipients") or [])[:100]
151
+ message.attachments = list(meta.get("attachments") or [])[:20]
152
+ message.present = True
153
+ if message in db.new and not initial and cfg.get("enabled"):
154
+ rule = next((r for r in active_rules if matches(r, message)), None)
155
+ if rule:
156
+ message.status, message.plan = "queued", snapshot(rule, cfg)
157
+ await db.commit() # message discovery and its initial job are durable together
158
+ if len(data["emails"]) < 100 or page * 100 >= int(data.get("total") or 0):
159
+ break
160
+ else:
161
+ raise MailError("Mailbox exceeds the 10,000-message sync limit; move older mail to another folder.")
162
+ for key, message in existing.items():
163
+ if key not in seen:
164
+ message.present = False
165
+ await db.commit()
166
+ # Re-read configuration so a concurrent settings save cannot be overwritten.
167
+ latest = await config(db)
168
+ if source(latest) == identity:
169
+ await settings_store.put(db, "mail", {**latest, "indexed_source": identity,
170
+ "last_sync": now().isoformat(), "sync_error": ""})
171
+
172
+
173
+ async def body(db, cfg, message):
174
+ if message.body is not None:
175
+ return
176
+ data = payload(await call(db, cfg, "get_emails_content", {
177
+ **mail_args(cfg), "email_ids": [message.uid], "mark_as_read": False, "max_body_length": 50000}))
178
+ entries = data.get("emails") or []
179
+ if not entries or (str(entries[0].get("message_id") or "") != message.message_id):
180
+ raise MailError("Message is no longer available at this mailbox location. Refresh the inbox.")
181
+ message.body = str(entries[0].get("body") or "")
182
+ await db.commit()
183
+
184
+
185
+ async def attachment(db, cfg, message, name):
186
+ if name not in message.attachments or not name.lower().endswith('.pdf'):
187
+ raise MailError("Choose a PDF attachment from this message.")
188
+ result = await call(db, cfg, "get_attachment_content", {
189
+ **mail_args(cfg), "email_id": message.uid, "attachment_name": name})
190
+ blobs = [b.resource.blob for b in result.content
191
+ if getattr(b, "type", None) == "resource" and hasattr(b.resource, "blob")]
192
+ if len(blobs) != 1 or len(blobs[0]) > (MAX_PDF + 2) // 3 * 4:
193
+ raise MailError("The email service did not return a PDF within the 5 MiB transfer limit.")
194
+ try:
195
+ data = base64.b64decode(blobs[0], validate=True)
196
+ except (ValueError, TypeError) as exc:
197
+ raise MailError("The email service returned invalid attachment data.") from exc
198
+ if len(data) > MAX_PDF or not data.startswith(b'%PDF-'):
199
+ raise MailError("Attachment is not a PDF or exceeds the 5 MiB transfer limit.")
200
+ return data
201
+
202
+
203
+ async def verify_message(db, cfg, message):
204
+ data = payload(await call(db, cfg, "get_emails_content", {
205
+ **mail_args(cfg), "email_ids": [message.uid], "mark_as_read": False, "max_body_length": 1}))
206
+ entries = data.get("emails") or []
207
+ if (not entries or str(entries[0].get("message_id") or "") != message.message_id
208
+ or str(entries[0].get("sender") or "")[:4000] != message.sender
209
+ or str(entries[0].get("subject") or "")[:4000] != message.subject):
210
+ raise MailError("Message identity changed or the message moved. Refresh the inbox before processing.")
211
+
212
+
213
+ async def upload(cfg, message, name, content):
214
+ endpoint = message.plan.get("destination")
215
+ if not endpoint or endpoint != cfg.get("budget_url") or not cfg.get("budget_token"):
216
+ raise MailError("The budget destination is missing or has changed. Restore it before retrying this delivery.")
217
+ # URL is fixed by the owner, never taken from the email. Never follow a redirect with the token.
218
+ async with httpx.AsyncClient(timeout=60, follow_redirects=False) as http:
219
+ response = await http.post(endpoint, content=content,
220
+ headers={"Authorization": "Bearer " + cfg["budget_token"], "Content-Type": "application/pdf"},
221
+ params={"filename": name, "source_id": message.id, "sender": message.sender[:320],
222
+ "subject": message.subject[:500]})
223
+ if response.status_code not in (200, 201):
224
+ raise MailError(f"Budget delivery returned HTTP {response.status_code}. Check the connection and retry.")
225
+ try:
226
+ receipt = response.json()
227
+ if receipt["sha256"] != hashlib.sha256(content).hexdigest() or not isinstance(receipt["id"], int):
228
+ raise ValueError()
229
+ except (ValueError, KeyError, TypeError) as exc:
230
+ raise MailError("Budget did not confirm the PDF checksum. Retry to verify delivery.") from exc
231
+ return {"id": receipt["id"], "sha256": receipt["sha256"]}
232
+
233
+
234
+ async def advance(db, cfg, message):
235
+ if message.status == "pending" and message.request_id:
236
+ request = await db.get(AccessRequest, message.request_id)
237
+ if request and request.status == "pending":
238
+ return
239
+ if not request or request.status != "approved":
240
+ message.status, message.note = "failed", "Access approval was denied or expired."
241
+ await db.commit()
242
+ return
243
+ message.status, message.request_id = "running", None
244
+ await db.commit()
245
+ try:
246
+ if source(cfg) != message.source:
247
+ raise MailError("The mailbox connection changed. Restore it before retrying this job.")
248
+ await verify_message(db, cfg, message)
249
+ action = message.plan.get("action")
250
+ if action == "pdf":
251
+ names = [a for a in message.attachments if isinstance(a, str) and a.lower().endswith('.pdf')]
252
+ if not names:
253
+ raise MailError("This message has no PDF attachment.")
254
+ if len(set(names)) != len(names):
255
+ raise MailError("Attachments have duplicate filenames; delivery needs manual review.")
256
+ for name in names:
257
+ if name in message.receipts:
258
+ continue
259
+ if len(name) > 200 or any(c in name for c in '/\\') or any(ord(c) < 32 for c in name):
260
+ raise MailError("The PDF filename cannot be delivered safely; download it for manual review.")
261
+ content = await attachment(db, cfg, message, name)
262
+ receipt = await upload(cfg, message, name, content)
263
+ message.receipts = {**message.receipts, name: receipt}
264
+ await audit.record(db, tool="mail_pdf_delivery", decision="allow", detail={
265
+ "message": message.id, "rule": message.plan.get("name"), "receipt": receipt})
266
+ elif action == "move" and not message.receipts.get("moved"):
267
+ await call(db, cfg, "move_emails", {"account_name": cfg["account"], "email_ids": [message.uid],
268
+ "source_mailbox": cfg["mailbox"], "destination_mailbox": message.plan["target"]})
269
+ message.receipts = {"moved": True}
270
+ message.present = False
271
+ elif action != "mark_read" and action != "move":
272
+ raise MailError("Unknown mail action.")
273
+ if action != "move" and not message.receipts.get("marked_read"):
274
+ await call(db, cfg, "mark_emails_as_read", {**mail_args(cfg), "email_ids": [message.uid]})
275
+ message.receipts = {**message.receipts, "marked_read": True}
276
+ message.status, message.note = "done", "PDFs delivered to Budget." if action == "pdf" else "Rule completed."
277
+ except Pending as exc:
278
+ message.status, message.note, message.request_id = "pending", str(exc), exc.request_id
279
+ except MailError as exc:
280
+ message.status, message.note = "failed", str(exc)
281
+ except Exception: # noqa: BLE001 - persist a safe, retryable failure without upstream secrets
282
+ message.status, message.note = "failed", "The service could not finish this job. Retry when the connection is available."
283
+ log.warning("Mail job %s failed", message.id) # no upstream secrets or email content
284
+ message.updated_at = now()
285
+ await db.commit()
286
+
287
+
288
+ async def tick():
289
+ async with lock, session() as db:
290
+ cfg = await config(db)
291
+ if not cfg.get("enabled"):
292
+ return
293
+ try:
294
+ await sync(db, cfg)
295
+ except Exception as exc: # noqa: BLE001 - keep jobs moving when mailbox sync is unavailable
296
+ latest = await config(db)
297
+ await settings_store.put(db, "mail", {**latest, "sync_error": str(exc) if isinstance(exc, MailError)
298
+ else "Mailbox sync failed. Check the connection and access permissions."})
299
+ jobs = (await db.scalars(select(MailMessage).where(
300
+ MailMessage.source == source(cfg), MailMessage.status.in_(["queued", "running", "pending"])
301
+ ).order_by(MailMessage.created_at).limit(50))).all()
302
+ for message in jobs:
303
+ await advance(db, cfg, message)
304
+
305
+
306
+ async def worker():
307
+ while True:
308
+ try:
309
+ await tick()
310
+ except Exception: # noqa: BLE001 - one cycle must not stop the worker
311
+ log.warning("Mail worker could not complete a cycle")
312
+ await asyncio.sleep(60)