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,192 @@
1
+ """Authenticated inventory forms; manual changes are recorded in the existing audit log."""
2
+ from fastapi import APIRouter, Depends, Request
3
+ from sqlalchemy import select
4
+ from sqlalchemy.exc import IntegrityError
5
+
6
+ from .. import audit
7
+ from .. import inventory as inv
8
+ from ..db import session
9
+ from ..models import DiscoveryLink, DiscoveryObservation, Resource
10
+ from . import auth
11
+ from .admin import redirect, render
12
+ from .security import require_same_origin
13
+
14
+ router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
15
+
16
+
17
+ async def context():
18
+ async with session() as db:
19
+ data = await inv.snapshot(db)
20
+ sources = (await db.execute(select(DiscoveryLink, DiscoveryObservation, Resource)
21
+ .join(DiscoveryObservation, DiscoveryLink.observation_id == DiscoveryObservation.id)
22
+ .join(Resource, DiscoveryObservation.resource_id == Resource.id))).all()
23
+ data['discovery_sources'] = {}
24
+ for link, observation, resource in sources:
25
+ data['discovery_sources'].setdefault(link.asset_id, []).append(
26
+ {'link': link, 'observation': observation, 'resource': resource})
27
+ return {**data, "kinds": inv.KINDS, "media": inv.MEDIA, "states": inv.STATES}
28
+
29
+
30
+ @router.get("/ui/inventory")
31
+ async def inventory_page(request: Request, q: str = "", sort: str = "name", group: str = "none"):
32
+ data = await context()
33
+ return render(request, "inventory.html", **data, rows=inv.asset_rows(data, q, sort, group),
34
+ q=q, sort=sort, group=group)
35
+
36
+
37
+ @router.get("/ui/inventory/topology")
38
+ async def topology_page(request: Request):
39
+ data = await context()
40
+ return render(request, "inventory_topology.html", **data, tree=inv.hosting_tree(data))
41
+
42
+
43
+ @router.get("/ui/inventory/networks")
44
+ async def networks_page(request: Request):
45
+ data = await context()
46
+ summaries = {s.id: inv.subnet_summary(s, data["allocations"]) for s in data["subnets"]}
47
+ return render(request, "inventory_networks.html", **data, summaries=summaries)
48
+
49
+
50
+ def asset_context(data, asset, values=None):
51
+ asset_id = asset.id if asset else None
52
+ interfaces = [i for i in data["interfaces"] if i.asset_id == asset_id]
53
+ linked_ids = {x.resource_id for x in data["links"]}
54
+ return {**data, "asset": asset, "values": values or {}, "asset_interfaces": interfaces,
55
+ "asset_allocations": [a for a in data["allocations"] if a.interface_id in {i.id for i in interfaces}],
56
+ "asset_resources": [data["resource_map"][x.resource_id] for x in data["links"]
57
+ if x.asset_id == asset_id and x.resource_id in data["resource_map"]],
58
+ "unlinked_resources": [r for r in data["resources"] if r.id not in linked_ids],
59
+ "children": [a for a in data["assets"] if a.parent_id == asset_id] if asset else []}
60
+
61
+
62
+ @router.get("/ui/inventory/assets/new")
63
+ async def asset_new(request: Request, resource: str = ""):
64
+ data = await context()
65
+ res = data["resource_map"].get(resource)
66
+ values = {"name": res.name, "resource_id": res.id} if res else {}
67
+ return render(request, "inventory_asset.html", **asset_context(data, None, values))
68
+
69
+
70
+ @router.get("/ui/inventory/assets/{asset_id}")
71
+ async def asset_page(request: Request, asset_id: str):
72
+ data = await context()
73
+ asset = data["asset_map"].get(asset_id)
74
+ if not asset:
75
+ return redirect("/ui/inventory", "Infrastructure record not found.")
76
+ return render(request, "inventory_asset.html", **asset_context(data, asset))
77
+
78
+
79
+ @router.get("/ui/inventory/networks/new")
80
+ async def network_new(request: Request):
81
+ return render(request, "inventory_subnet.html", **await context(), subnet=None, values={}, summary=None)
82
+
83
+
84
+ @router.get("/ui/inventory/networks/{subnet_id}")
85
+ async def network_page(request: Request, subnet_id: str):
86
+ data = await context()
87
+ subnet = data["subnet_map"].get(subnet_id)
88
+ if not subnet:
89
+ return redirect("/ui/inventory/networks", "Subnet not found.")
90
+ summary = inv.subnet_summary(subnet, data["allocations"])
91
+ return render(request, "inventory_subnet.html", **data, subnet=subnet, values={}, summary=summary)
92
+
93
+
94
+ @router.post("/ui/inventory/assets")
95
+ async def asset_save(request: Request):
96
+ form = await request.form()
97
+ try:
98
+ async with session() as db:
99
+ await inv.lock_writes(db)
100
+ asset = await inv.save_asset(db, form)
101
+ if form.get("resource_id"):
102
+ await inv.link_resource(db, {"asset_id": asset.id, "resource_id": form["resource_id"]})
103
+ await audit.record(db, tool="inventory.asset.save", decision="saved",
104
+ detail={"id": asset.id, "name": asset.name})
105
+ asset_id = asset.id
106
+ except (inv.InventoryError, IntegrityError) as exc:
107
+ data = await context()
108
+ error = str(exc) if isinstance(exc, inv.InventoryError) else "A conflicting record was saved. Refresh and try again."
109
+ return render(request, "inventory_asset.html", error=error,
110
+ **asset_context(data, data["asset_map"].get(form.get("id")), dict(form)))
111
+ return redirect(f"/ui/inventory/assets/{asset_id}", "Infrastructure record saved.")
112
+
113
+
114
+ @router.post("/ui/inventory/networks")
115
+ async def network_save(request: Request):
116
+ form = await request.form()
117
+ try:
118
+ async with session() as db:
119
+ await inv.lock_writes(db)
120
+ subnet = await inv.save_subnet(db, form)
121
+ await audit.record(db, tool="inventory.subnet.save", decision="saved",
122
+ detail={"id": subnet.id, "cidr": subnet.cidr, "scope": subnet.scope})
123
+ subnet_id = subnet.id
124
+ except (inv.InventoryError, IntegrityError) as exc:
125
+ data = await context()
126
+ subnet = data["subnet_map"].get(form.get("id"))
127
+ error = str(exc) if isinstance(exc, inv.InventoryError) else "A conflicting subnet was saved. Refresh and try again."
128
+ return render(request, "inventory_subnet.html", **data, subnet=subnet, values=dict(form), error=error,
129
+ summary=inv.subnet_summary(subnet, data["allocations"]) if subnet else None)
130
+ return redirect(f"/ui/inventory/networks/{subnet_id}", "Subnet saved.")
131
+
132
+
133
+ @router.post("/ui/inventory/interfaces")
134
+ async def interface_save(request: Request):
135
+ return await detail_save(request, "interface", inv.save_interface)
136
+
137
+
138
+ @router.post("/ui/inventory/allocations")
139
+ async def allocation_save(request: Request):
140
+ return await detail_save(request, "allocation", inv.save_allocation)
141
+
142
+
143
+ @router.post("/ui/inventory/links")
144
+ async def link_save(request: Request):
145
+ return await detail_save(request, "link", inv.link_resource)
146
+
147
+
148
+ async def detail_save(request, kind, save):
149
+ form = await request.form()
150
+ try:
151
+ async with session() as db:
152
+ await inv.lock_writes(db)
153
+ row = await save(db, form)
154
+ row_id = row.resource_id if kind == "link" else row.id
155
+ await audit.record(db, tool=f"inventory.{kind}.save", decision="saved",
156
+ detail={"id": row_id, "record": dict(form)})
157
+ except (inv.InventoryError, IntegrityError) as exc:
158
+ error = str(exc) if isinstance(exc, inv.InventoryError) else "A conflicting record was saved. Refresh and try again."
159
+ data = await context()
160
+ if kind == "allocation":
161
+ subnet = data["subnet_map"].get(form.get("subnet_id"))
162
+ if subnet:
163
+ return render(request, "inventory_subnet.html", **data, subnet=subnet, values={},
164
+ summary=inv.subnet_summary(subnet, data["allocations"]), error=error,
165
+ allocation_values=dict(form))
166
+ else:
167
+ asset = data["asset_map"].get(form.get("asset_id"))
168
+ if asset:
169
+ return render(request, "inventory_asset.html", **asset_context(data, asset), error=error,
170
+ interface_values=dict(form) if kind == "interface" else {})
171
+ return redirect("/ui/inventory", error)
172
+ destination = (f"/ui/inventory/networks/{row.subnet_id}" if kind == "allocation"
173
+ else f"/ui/inventory/assets/{row.asset_id}")
174
+ return redirect(destination, f"{kind.capitalize()} saved.")
175
+
176
+
177
+ @router.post("/ui/inventory/{kind}/{row_id}/delete")
178
+ async def record_delete(request: Request, kind: str, row_id: str):
179
+ form = await request.form()
180
+ try:
181
+ async with session() as db:
182
+ await inv.lock_writes(db)
183
+ row = await inv.delete_record(db, kind, row_id)
184
+ destination = {"asset": "/ui/inventory", "subnet": "/ui/inventory/networks"}.get(kind)
185
+ if destination is None:
186
+ destination = (f"/ui/inventory/networks/{row.subnet_id}" if kind == "allocation"
187
+ else f"/ui/inventory/assets/{row.asset_id}")
188
+ await audit.record(db, tool=f"inventory.{kind}.delete", decision="deleted", detail={"id": row_id})
189
+ except (inv.InventoryError, IntegrityError) as exc:
190
+ error = str(exc) if isinstance(exc, inv.InventoryError) else "This record is still in use."
191
+ return redirect(str(form.get("next", "/ui/inventory")), error)
192
+ return redirect(destination, "Record removed from inventory.")
@@ -0,0 +1,177 @@
1
+ """Admin knowledge publishing and proposal review; agent access stays behind MCP policy."""
2
+ import difflib
3
+
4
+ from fastapi import APIRouter, Depends, Request
5
+ from fastapi.responses import PlainTextResponse
6
+ from sqlalchemy import select
7
+ from sqlalchemy.exc import IntegrityError
8
+
9
+ from .. import audit, knowledge_index
10
+ from .. import knowledge as k
11
+ from ..db import session
12
+ from ..models import Resource, now
13
+ from . import auth
14
+ from .admin import redirect, render
15
+ from .security import require_same_origin
16
+
17
+ router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
18
+
19
+
20
+ @router.get("/ui/knowledge")
21
+ async def home(request: Request, collection: str = "", q: str = "", offset: int = 0):
22
+ async with session() as db:
23
+ collections = list(await db.scalars(select(Resource).where(Resource.kind == "mcp").order_by(Resource.name)))
24
+ collections = [r for r in collections if r.config.get("adapter") == "knowledge"]
25
+ selected = next((r for r in collections if r.id == collection), None)
26
+ documents, proposals, results = {"documents": [], "next_offset": None}, [], None
27
+ if selected:
28
+ documents = await k.list_documents(db, selected.id, max(0, offset), 50)
29
+ proposals = (await db.execute(select(k.Revision, k.Document).join(k.Document)
30
+ .where(k.Document.resource_id == selected.id, k.Revision.status == "pending")
31
+ .order_by(k.Revision.created_at).limit(100))).all()
32
+ if q.strip():
33
+ try:
34
+ results = await k.search(db, selected.id, q)
35
+ except k.KnowledgeError as exc:
36
+ results = {"results": [], "mode": "text", "warning": str(exc)}
37
+ await audit.record(db, tool="knowledge.search", decision="read", detail={"collection": selected.id})
38
+ return render(request, "knowledge.html", collections=collections, selected=selected, documents=documents,
39
+ proposals=proposals, results=results, q=q, kinds=k.KINDS,
40
+ semantic=knowledge_index.backend() is not None)
41
+
42
+
43
+ @router.post("/ui/knowledge/collections")
44
+ async def create(request: Request):
45
+ form = await request.form()
46
+ try:
47
+ async with session() as db:
48
+ await k.lock_writes(db)
49
+ res = await k.create_collection(db, str(form.get("name", "")).strip(), str(form.get("description", "")))
50
+ await audit.record(db, tool="knowledge.collection.create", decision="saved", detail={"collection": res.id})
51
+ rid = res.id
52
+ except (k.KnowledgeError, IntegrityError) as exc:
53
+ return redirect("/ui/knowledge", str(exc) if isinstance(exc, k.KnowledgeError) else "Collection already exists.")
54
+ return redirect("/ui/knowledge?collection=" + rid, "Collection created. Grant agents access using Access rules.")
55
+
56
+
57
+ @router.post("/ui/knowledge/{rid}/publish")
58
+ async def publish(request: Request, rid: str):
59
+ form = await request.form()
60
+ try:
61
+ body, version = str(form.get("body", "")), ""
62
+ path = str(form.get("path", ""))
63
+ source = str(form.get("source", ""))
64
+ if form.get("import_git") == "1":
65
+ body, version = await k.git_markdown(path)
66
+ source = "git:" + path
67
+ async with session() as db:
68
+ await k.lock_writes(db)
69
+ rev = await k.propose(db, rid, path=path, title=str(form.get("title", "")), body=body,
70
+ kind=str(form.get("kind", "documentation")), source=source, source_version=version,
71
+ expected_revision=str(form.get("expected_revision", "")) or None,
72
+ retention_days=int(form.get("retention_days", "30")))
73
+ await k.review(db, rev.id, True)
74
+ await audit.record(db, tool="knowledge.publish", decision="published",
75
+ detail={"collection": rid, "revision": rev.id, "sha256": rev.sha256, "source_version": version})
76
+ except (k.KnowledgeError, ValueError, IntegrityError) as exc:
77
+ return redirect("/ui/knowledge?collection=" + rid,
78
+ str(exc) if isinstance(exc, k.KnowledgeError) else "Invalid or conflicting document; refresh and retry.")
79
+ return redirect("/ui/knowledge?collection=" + rid, "Published and text-indexed. Rebuild the semantic index to update it.")
80
+
81
+
82
+ @router.get("/ui/knowledge/documents/{did}")
83
+ async def document(request: Request, did: str, download: bool = False):
84
+ async with session() as db:
85
+ doc = await db.get(k.Document, did)
86
+ if not doc:
87
+ return redirect("/ui/knowledge", "Document not found.")
88
+ try:
89
+ res = await k.collection(db, doc.resource_id)
90
+ data = await k.get_document(db, res.id, did)
91
+ except k.KnowledgeError as exc:
92
+ return redirect("/ui/knowledge", str(exc))
93
+ await audit.record(db, tool="knowledge.get", decision="read", detail={"document": did, "revision": data["revision"]})
94
+ if download:
95
+ return PlainTextResponse(data["markdown"], media_type="text/markdown",
96
+ headers={"Content-Disposition": 'attachment; filename="knowledge.md"'})
97
+ return render(request, "knowledge_document.html", doc=data, collection=res, kinds=k.KINDS)
98
+
99
+
100
+ @router.get("/ui/knowledge/proposals/{revision_id}")
101
+ async def proposal(request: Request, revision_id: str):
102
+ async with session() as db:
103
+ rev = await db.get(k.Revision, revision_id)
104
+ if not rev:
105
+ return redirect("/ui/knowledge", "Proposal not found.")
106
+ doc = await db.get(k.Document, rev.document_id)
107
+ try:
108
+ res = await k.collection(db, doc.resource_id)
109
+ body = k.markdown(rev)
110
+ old = await db.get(k.Revision, doc.current_revision) if doc.current_revision else None
111
+ previous = k.markdown(old) if old else ""
112
+ except k.KnowledgeError as exc:
113
+ return redirect("/ui/knowledge", str(exc))
114
+ diff = "".join(difflib.unified_diff(previous.splitlines(True), body.splitlines(True),
115
+ fromfile="Current published Markdown", tofile="Proposed Markdown"))
116
+ return render(request, "knowledge_proposal.html", revision=rev, doc=doc, collection=res, body=body, diff=diff)
117
+
118
+
119
+ @router.post("/ui/knowledge/proposals/{revision_id}")
120
+ async def review(request: Request, revision_id: str):
121
+ form = await request.form()
122
+ try:
123
+ if form.get("action") not in ("approve", "reject"):
124
+ raise k.KnowledgeError("Choose approve or reject.")
125
+ async with session() as db:
126
+ await k.lock_writes(db)
127
+ rev = await k.review(db, revision_id, form["action"] == "approve")
128
+ doc = await db.get(k.Document, rev.document_id)
129
+ rid = doc.resource_id
130
+ await audit.record(db, tool="knowledge.review", decision=rev.status,
131
+ detail={"revision": rev.id, "collection": rid, "sha256": rev.sha256})
132
+ except k.KnowledgeError as exc:
133
+ return redirect("/ui/knowledge/proposals/" + revision_id, str(exc))
134
+ return redirect("/ui/knowledge?collection=" + rid, "Proposal " + rev.status + ".")
135
+
136
+
137
+ @router.post("/ui/knowledge/{rid}/archive")
138
+ async def archive(request: Request, rid: str):
139
+ form = await request.form()
140
+ try:
141
+ async with session() as db:
142
+ await k.lock_writes(db)
143
+ await k.archive(db, rid, str(form.get("document_id", "")), str(form.get("expected_revision", "")))
144
+ await audit.record(db, tool="knowledge.archive", decision="archived",
145
+ detail={"collection": rid, "document": str(form.get("document_id", ""))})
146
+ except k.KnowledgeError as exc:
147
+ return redirect("/ui/knowledge?collection=" + rid, str(exc))
148
+ return redirect("/ui/knowledge?collection=" + rid, "Archived. This document is no longer returned to agents.")
149
+
150
+
151
+ @router.post("/ui/knowledge/{rid}/reindex")
152
+ async def reindex(rid: str):
153
+ try:
154
+ async with session() as db:
155
+ count = await k.reindex(db, rid)
156
+ res = await k.collection(db, rid)
157
+ res.config = {**res.config, "knowledge_indexed_at": now().isoformat(), "knowledge_indexed_chunks": count}
158
+ await audit.record(db, tool="knowledge.reindex", decision="indexed", detail={"collection": rid, "chunks": count})
159
+ except (k.KnowledgeError, knowledge_index.IndexUnavailable) as exc:
160
+ return redirect("/ui/knowledge?collection=" + rid, str(exc))
161
+ return redirect("/ui/knowledge?collection=" + rid, f"Indexed {count} chunks for hybrid retrieval.")
162
+
163
+
164
+ @router.post("/ui/knowledge/{rid}/rechunk")
165
+ async def rechunk(rid: str):
166
+ try:
167
+ async with session() as db:
168
+ await k.lock_writes(db)
169
+ result = await k.rechunk(db, rid)
170
+ await audit.record(db, tool="knowledge.rechunk", decision="rechunked", detail={"collection": rid, **result})
171
+ except k.KnowledgeError as exc:
172
+ return redirect("/ui/knowledge?collection=" + rid, str(exc))
173
+ message = (f"Updated search passages for {result['changed_documents']} documents. "
174
+ "Published content and review history are unchanged.")
175
+ if result["changed_documents"] and knowledge_index.backend() is not None:
176
+ message += " Rebuild the semantic index to include the new passages."
177
+ return redirect("/ui/knowledge?collection=" + rid, message)
@@ -0,0 +1,190 @@
1
+ """Owner-authenticated inbox, rule editor, and delivery history."""
2
+ from urllib.parse import quote
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, Request
5
+ from fastapi.responses import Response
6
+ from sqlalchemy import func, or_, select
7
+
8
+ from .. import audit, mail, settings_store
9
+ from ..db import session
10
+ from ..models import Agent, MailMessage, MailRule, Resource
11
+ from . import auth
12
+ from .admin import redirect, render
13
+ from .security import require_same_origin
14
+
15
+ router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
16
+
17
+
18
+ async def get_message(db, cfg, identifier):
19
+ message = await db.get(MailMessage, identifier)
20
+ if not message or message.source != mail.source(cfg):
21
+ raise HTTPException(404, "Message not found")
22
+ return message
23
+
24
+
25
+ @router.get('/ui/mail')
26
+ async def inbox(request: Request, selected: str = '', status: str = '', q: str = '', page: int = 1,
27
+ view: str = 'inbox'):
28
+ async with session() as db:
29
+ cfg = await mail.config(db)
30
+ conditions = [MailMessage.source == mail.source(cfg)]
31
+ if view != 'history':
32
+ conditions.append(MailMessage.present.is_(True))
33
+ if status in mail.STATUS:
34
+ conditions.append(MailMessage.status == status)
35
+ if q:
36
+ conditions.append(or_(MailMessage.subject.contains(q[:200], autoescape=True),
37
+ MailMessage.sender.contains(q[:200], autoescape=True)))
38
+ total = await db.scalar(select(func.count()).select_from(MailMessage).where(*conditions))
39
+ pages = max(1, (total + 49) // 50)
40
+ page = max(1, min(page, pages))
41
+ messages = (await db.scalars(select(MailMessage).where(*conditions)
42
+ .order_by(MailMessage.date.desc(), MailMessage.created_at.desc())
43
+ .offset((page - 1) * 50).limit(50))).all()
44
+ message, error = None, ''
45
+ if selected:
46
+ message = await get_message(db, cfg, selected)
47
+ try:
48
+ await mail.body(db, cfg, message)
49
+ except mail.MailError as exc:
50
+ error = str(exc)
51
+ counts = dict((await db.execute(select(MailMessage.status, func.count()).where(
52
+ MailMessage.source == mail.source(cfg), MailMessage.present.is_(True)
53
+ ).group_by(MailMessage.status))).all())
54
+ return render(request, 'mail.html', cfg=cfg, messages=messages, message=message, counts=counts,
55
+ statuses=mail.STATUS, selected=selected, status=status, q=q, page=page, pages=pages,
56
+ total=total, view=view, error=error)
57
+
58
+
59
+ @router.post('/ui/mail/sync')
60
+ async def sync_mail():
61
+ async with mail.lock, session() as db:
62
+ try:
63
+ await mail.sync(db, await mail.config(db))
64
+ except mail.MailError as exc:
65
+ return redirect('/ui/mail', str(exc))
66
+ except Exception: # noqa: BLE001 - upstream connection failures must not expose credentials
67
+ return redirect('/ui/mail', 'Mailbox sync failed. Check the email connection and audit log.')
68
+ return redirect('/ui/mail', 'Inbox refreshed.')
69
+
70
+
71
+ @router.post('/ui/mail/{identifier}/run')
72
+ async def run_message(identifier: str):
73
+ async with mail.lock, session() as db:
74
+ cfg = await mail.config(db)
75
+ message = await get_message(db, cfg, identifier)
76
+ if message.status == 'failed' and message.plan:
77
+ # Keep the original plan and receipts: a retry never silently changes destinations.
78
+ message.status, message.request_id = 'queued', None
79
+ await db.commit()
80
+ else:
81
+ await mail.queue(db, cfg, message)
82
+ if message.status in {'queued', 'running', 'pending'}:
83
+ await mail.advance(db, cfg, message)
84
+ await audit.record(db, tool='mail_run', decision='allow', detail={'message': identifier})
85
+ return redirect('/ui/mail?selected=' + identifier)
86
+
87
+
88
+ @router.get('/ui/mail/{identifier}/attachment')
89
+ async def download(identifier: str, name: str):
90
+ async with session() as db:
91
+ cfg = await mail.config(db)
92
+ message = await get_message(db, cfg, identifier)
93
+ try:
94
+ await mail.verify_message(db, cfg, message)
95
+ content = await mail.attachment(db, cfg, message, name)
96
+ except mail.MailError as exc:
97
+ return redirect('/ui/mail?selected=' + identifier, str(exc))
98
+ return Response(content, media_type='application/pdf', headers={
99
+ 'Content-Disposition': "attachment; filename*=UTF-8''" + quote(name, safe=''),
100
+ 'Cache-Control': 'no-store',
101
+ })
102
+
103
+
104
+ @router.get('/ui/mail/rules')
105
+ async def rule_page(request: Request, edit: str = ''):
106
+ async with session() as db:
107
+ entries = await mail.rules(db)
108
+ rule = await db.get(MailRule, edit) if edit else None
109
+ return render(request, 'mail_rules.html', rules=entries, rule=rule)
110
+
111
+
112
+ @router.post('/ui/mail/rules/save')
113
+ async def save_rule(request: Request):
114
+ form = await request.form()
115
+ async with mail.lock, session() as db:
116
+ identifier = str(form.get('id') or '')
117
+ rule = await db.get(MailRule, identifier) if identifier else MailRule()
118
+ if rule is None:
119
+ raise HTTPException(404, 'Rule not found')
120
+ name, sender, subject = (str(form.get(k) or '').strip() for k in ('name', 'sender', 'subject'))
121
+ action, target = str(form.get('action') or ''), str(form.get('target') or '').strip()
122
+ if not name or len(name) > 120 or not (sender or subject) or len(sender) > 320 or len(subject) > 200:
123
+ return redirect('/ui/mail/rules', 'Give the rule a name and a sender or subject filter.')
124
+ if action not in {'pdf', 'mark_read', 'move'} or (action == 'move' and not target):
125
+ return redirect('/ui/mail/rules', 'Choose an action and a destination folder when moving mail.')
126
+ if len(target) > 200 or any(ord(c) < 32 for c in target):
127
+ return redirect('/ui/mail/rules', 'Use a valid destination folder name.')
128
+ try:
129
+ position = int(str(form.get('position') or '100'))
130
+ if not 0 <= position <= 9999:
131
+ raise ValueError()
132
+ except ValueError:
133
+ return redirect('/ui/mail/rules', 'Priority must be between 0 and 9999.')
134
+ rule.name, rule.sender, rule.subject = name, sender, subject
135
+ rule.action, rule.target, rule.position = action, target, position
136
+ rule.enabled = form.get('enabled') == 'on'
137
+ db.add(rule)
138
+ await db.flush()
139
+ await audit.record(db, tool='mail_rule_save', decision='allow', detail={'rule': rule.id, 'action': action})
140
+ return redirect('/ui/mail/rules', 'Rule saved. It applies to new mail; use Run rules for existing messages.')
141
+
142
+
143
+ @router.post('/ui/mail/rules/{identifier}/toggle')
144
+ async def toggle_rule(identifier: str):
145
+ async with mail.lock, session() as db:
146
+ rule = await db.get(MailRule, identifier)
147
+ if not rule:
148
+ raise HTTPException(404, 'Rule not found')
149
+ rule.enabled = not rule.enabled
150
+ await audit.record(db, tool='mail_rule_toggle', decision='allow', detail={'rule': identifier, 'enabled': rule.enabled})
151
+ return redirect('/ui/mail/rules')
152
+
153
+
154
+ @router.get('/ui/mail/settings')
155
+ async def settings_page(request: Request):
156
+ async with session() as db:
157
+ cfg = await mail.config(db)
158
+ resources = (await db.scalars(select(Resource).where(Resource.kind == 'mcp').order_by(Resource.name))).all()
159
+ agents = (await db.scalars(select(Agent).where(Agent.disabled_at.is_(None)).order_by(Agent.name))).all()
160
+ return render(request, 'mail_settings.html', cfg=cfg, resources=resources, agents=agents)
161
+
162
+
163
+ @router.post('/ui/mail/settings')
164
+ async def save_settings(request: Request):
165
+ form = await request.form()
166
+ async with mail.lock, session() as db:
167
+ cfg = await mail.config(db)
168
+ resource = await db.get(Resource, str(form.get('resource') or ''))
169
+ agent = await db.get(Agent, str(form.get('agent') or ''))
170
+ if not resource or resource.kind != 'mcp' or not agent or agent.disabled:
171
+ return redirect('/ui/mail/settings', 'Choose an email resource and an enabled agent.')
172
+ try:
173
+ endpoint = mail.budget_url(str(form.get('budget_url'))) if form.get('budget_url') else ''
174
+ account, mailbox = (str(form.get(k) or '').strip() for k in ('account', 'mailbox'))
175
+ if not account or not mailbox or max(len(account), len(mailbox)) > 200:
176
+ raise mail.MailError('Account and mailbox are required (up to 200 characters).')
177
+ token = str(form.get('budget_token') or '').strip()
178
+ if token and (not token.startswith('ba_docs_') or len(token) > 200):
179
+ raise mail.MailError('Use a document delivery token from Budget settings.')
180
+ if endpoint != cfg.get('budget_url', '') and endpoint and not token:
181
+ raise mail.MailError('Enter a delivery token when changing the budget server.')
182
+ except mail.MailError as exc:
183
+ return redirect('/ui/mail/settings', str(exc))
184
+ updated = {**cfg, 'resource': resource.id, 'agent': agent.id, 'account': account, 'mailbox': mailbox,
185
+ 'budget_url': endpoint, 'budget_token': token or cfg.get('budget_token', ''),
186
+ 'enabled': form.get('enabled') == 'on'}
187
+ await settings_store.put(db, 'mail', updated)
188
+ await audit.record(db, tool='mail_settings_save', decision='allow', detail={
189
+ 'resource': resource.id, 'agent': agent.id, 'enabled': updated['enabled']})
190
+ return redirect('/ui/mail/settings', 'Connection saved. Refresh the inbox to check access.')
@@ -0,0 +1,87 @@
1
+ """Administrator consent for broker-owned upstream OAuth connections."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+
6
+ from fastapi import APIRouter, Depends, Request
7
+ from fastapi.responses import HTMLResponse, RedirectResponse
8
+
9
+ from .. import audit, mcp_oauth, mcp_proxy
10
+ from ..db import session
11
+ from ..models import Resource
12
+ from . import auth
13
+ from .security import _origin, require_same_origin
14
+
15
+ router = APIRouter(dependencies=[Depends(require_same_origin), Depends(auth.require_admin)])
16
+ COOKIE = "kw_mcp_oauth_flow"
17
+ COOKIE_PATH = "/ui/mcp-oauth"
18
+
19
+
20
+ def failure(message: str):
21
+ # Only fixed local ConnectionError messages, never provider responses.
22
+ from html import escape
23
+ response = HTMLResponse(
24
+ '<!doctype html><title>Connect MCP</title><h1>Connection unsuccessful</h1>'
25
+ f'<p>{escape(message)}</p><p><a href="/ui/resources">Return to resources</a></p>', status_code=400,
26
+ )
27
+ response.delete_cookie(COOKIE, path=COOKIE_PATH, secure=True, httponly=True, samesite="lax")
28
+ return response
29
+
30
+
31
+ async def resource(rid: str) -> Resource:
32
+ async with session() as db:
33
+ res = await db.get(Resource, rid)
34
+ if not res or res.kind != "mcp" or res.config.get("auth_type") != "oauth":
35
+ raise mcp_oauth.ConnectionError("Save an HTTP MCP resource with OAuth authentication first.")
36
+ return res
37
+
38
+
39
+ @router.post("/ui/mcp-oauth/{rid}/connect")
40
+ async def connect(request: Request, rid: str):
41
+ try:
42
+ res = await resource(rid)
43
+ if _origin(str(request.url)) != _origin(mcp_oauth.callback_url()):
44
+ raise mcp_oauth.ConnectionError("Open Keywright at its configured public HTTPS URL to connect OAuth.")
45
+ browser = secrets.token_urlsafe(32)
46
+ url = await mcp_oauth.begin(res.config, browser, request.cookies.get(auth.COOKIE, ""))
47
+ mcp_proxy.forget(res.name)
48
+ except mcp_oauth.ConnectionError as exc:
49
+ return failure(str(exc))
50
+ # A cross-origin redirect after a form POST is blocked by form-action 'self'
51
+ # in browsers such as Safari and Chromium. Finish the POST with a page that
52
+ # starts a fresh navigation, retaining our same-origin form policy.
53
+ from .admin import render
54
+ response = render(request, "mcp_oauth_continue.html", authorization_url=url,
55
+ resource_name=res.name, resource_id=res.id)
56
+ response.set_cookie(COOKIE, browser, max_age=mcp_oauth.FLOW_TTL, path=COOKIE_PATH,
57
+ secure=True, httponly=True, samesite="lax")
58
+ return response
59
+
60
+
61
+ @router.get(mcp_oauth.CALLBACK_PATH)
62
+ async def callback(request: Request):
63
+ try:
64
+ rid = await mcp_oauth.finish(request.query_params, request.cookies.get(COOKIE, ""),
65
+ request.cookies.get(auth.COOKIE, ""))
66
+ res = await resource(rid)
67
+ except mcp_oauth.ConnectionError as exc:
68
+ return failure(str(exc))
69
+ mcp_proxy.forget(res.name)
70
+ async with session() as db:
71
+ await audit.record(db, tool="admin.mcp_oauth.connect", decision="allow", detail={"resource_id": rid})
72
+ response = RedirectResponse(f"/ui/resources/{rid}", status_code=303)
73
+ response.delete_cookie(COOKIE, path=COOKIE_PATH, secure=True, httponly=True, samesite="lax")
74
+ return response
75
+
76
+
77
+ @router.post("/ui/mcp-oauth/{rid}/disconnect")
78
+ async def disconnect(request: Request, rid: str):
79
+ try:
80
+ res = await resource(rid)
81
+ await mcp_oauth.disconnect(res.config)
82
+ except mcp_oauth.ConnectionError as exc:
83
+ return failure(str(exc))
84
+ mcp_proxy.forget(res.name)
85
+ async with session() as db:
86
+ await audit.record(db, tool="admin.mcp_oauth.disconnect", decision="allow", detail={"resource_id": rid})
87
+ return RedirectResponse(f"/ui/resources/{rid}", status_code=303)