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,442 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import UTC, datetime
5
+ from typing import Any, ClassVar
6
+
7
+ from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text, TypeDecorator
8
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
9
+
10
+
11
+ def now() -> datetime:
12
+ return datetime.now(UTC)
13
+
14
+
15
+ class UTCDateTime(TypeDecorator):
16
+ """Timezone-aware UTC datetimes on every backend (SQLite drops tzinfo otherwise)."""
17
+
18
+ impl = DateTime(timezone=True)
19
+ cache_ok = True
20
+
21
+ def process_bind_param(self, value, dialect):
22
+ if value is None:
23
+ return None
24
+ if value.tzinfo is None:
25
+ return value.replace(tzinfo=UTC)
26
+ return value.astimezone(UTC)
27
+
28
+ def process_result_value(self, value, dialect):
29
+ if value is None:
30
+ return None
31
+ return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
32
+
33
+
34
+ def new_id(prefix: str) -> str:
35
+ return f"{prefix}_{secrets.token_urlsafe(9)}"
36
+
37
+
38
+ class Base(DeclarativeBase):
39
+ type_annotation_map: ClassVar[dict] = {dict[str, Any]: JSON, list[Any]: JSON}
40
+
41
+
42
+ class KnowledgeDocument(Base):
43
+ __tablename__ = "knowledge_documents"
44
+ __table_args__ = (Index("knowledge_document_path", "resource_id", "path", unique=True),)
45
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("doc"))
46
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), index=True)
47
+ path: Mapped[str] = mapped_column(String)
48
+ current_revision: Mapped[str | None] = mapped_column(String, nullable=True)
49
+
50
+
51
+ class KnowledgeRevision(Base):
52
+ __tablename__ = "knowledge_revisions"
53
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("rev"))
54
+ document_id: Mapped[str] = mapped_column(ForeignKey("knowledge_documents.id", ondelete="CASCADE"), index=True)
55
+ title: Mapped[str] = mapped_column(String)
56
+ kind: Mapped[str] = mapped_column(String, default="documentation")
57
+ sha256: Mapped[str] = mapped_column(String)
58
+ # Markdown is immutable, content-addressed on disk. The database holds its provenance and index.
59
+ source: Mapped[str] = mapped_column(Text, default="")
60
+ source_version: Mapped[str] = mapped_column(String, default="")
61
+ base_revision: Mapped[str | None] = mapped_column(String, nullable=True)
62
+ proposed_by: Mapped[str | None] = mapped_column(ForeignKey("agents.id", ondelete="SET NULL"), nullable=True)
63
+ attribution: Mapped[str] = mapped_column(String, default="admin")
64
+ rationale: Mapped[str] = mapped_column(Text, default="")
65
+ status: Mapped[str] = mapped_column(String, default="pending", index=True)
66
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
67
+ reviewed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
68
+ expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
69
+
70
+
71
+ class KnowledgeChunk(Base):
72
+ __tablename__ = "knowledge_chunks"
73
+ id: Mapped[str] = mapped_column(String, primary_key=True)
74
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), index=True)
75
+ revision_id: Mapped[str] = mapped_column(ForeignKey("knowledge_revisions.id", ondelete="CASCADE"), index=True)
76
+ ordinal: Mapped[int] = mapped_column(Integer)
77
+ line_start: Mapped[int] = mapped_column(Integer)
78
+ line_end: Mapped[int] = mapped_column(Integer)
79
+ title: Mapped[str] = mapped_column(Text)
80
+ body: Mapped[str] = mapped_column(Text)
81
+ section_path: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
82
+
83
+
84
+ class MailMessage(Base):
85
+ __tablename__ = "mail_messages"
86
+ id: Mapped[str] = mapped_column(String, primary_key=True)
87
+ source: Mapped[str] = mapped_column(String, index=True)
88
+ uid: Mapped[str] = mapped_column(String)
89
+ message_id: Mapped[str] = mapped_column(String, default="")
90
+ sender: Mapped[str] = mapped_column(Text, default="")
91
+ recipients: Mapped[list[Any]] = mapped_column(JSON, default=list)
92
+ subject: Mapped[str] = mapped_column(Text, default="")
93
+ date: Mapped[str] = mapped_column(String, default="")
94
+ attachments: Mapped[list[Any]] = mapped_column(JSON, default=list)
95
+ body: Mapped[str | None] = mapped_column(Text, nullable=True)
96
+ status: Mapped[str] = mapped_column(String, default="unprocessed", index=True)
97
+ note: Mapped[str] = mapped_column(Text, default="")
98
+ plan: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
99
+ receipts: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
100
+ request_id: Mapped[str | None] = mapped_column(String, nullable=True)
101
+ present: Mapped[bool] = mapped_column(default=True)
102
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
103
+ updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
104
+
105
+
106
+ class MailRule(Base):
107
+ __tablename__ = "mail_rules"
108
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("mailrule"))
109
+ name: Mapped[str] = mapped_column(String)
110
+ sender: Mapped[str] = mapped_column(String, default="")
111
+ subject: Mapped[str] = mapped_column(String, default="")
112
+ action: Mapped[str] = mapped_column(String, default="pdf")
113
+ target: Mapped[str] = mapped_column(String, default="")
114
+ enabled: Mapped[bool] = mapped_column(default=True)
115
+ position: Mapped[int] = mapped_column(Integer, default=100)
116
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
117
+
118
+
119
+ class Agent(Base):
120
+ """An identity that talks MCP to us. One bearer token each; revoking it revokes everything."""
121
+
122
+ __tablename__ = "agents"
123
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("agt"))
124
+ name: Mapped[str] = mapped_column(String, unique=True)
125
+ token_hash: Mapped[str] = mapped_column(String, unique=True)
126
+ token_prefix: Mapped[str] = mapped_column(String, default="")
127
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
128
+ disabled_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
129
+ last_seen_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
130
+ note: Mapped[str] = mapped_column(Text, default="")
131
+
132
+ @property
133
+ def disabled(self) -> bool:
134
+ return self.disabled_at is not None
135
+
136
+
137
+ class Resource(Base):
138
+ """Something an agent may want: an SSH host, an upstream MCP server, or an LLM gateway.
139
+
140
+ `default_mode` is the answer when no policy or grant speaks:
141
+ open - allowed, audited
142
+ approval - ask a human
143
+ locked - never grantable from a notification; only an explicit policy in the UI
144
+ """
145
+
146
+ __tablename__ = "resources"
147
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("res"))
148
+ kind: Mapped[str] = mapped_column(String) # ssh | mcp | llm
149
+ name: Mapped[str] = mapped_column(String, unique=True) # second path segment: ssh/<name>/...
150
+ default_mode: Mapped[str] = mapped_column(String, default="approval")
151
+ config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
152
+ description: Mapped[str] = mapped_column(Text, default="")
153
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
154
+
155
+ @property
156
+ def path_prefix(self) -> str:
157
+ return f"{self.kind}/{self.name}"
158
+
159
+
160
+ class MCPOAuthConnection(Base):
161
+ """Broker-owned OAuth credentials, separate from agent-visible resource configuration."""
162
+
163
+ __tablename__ = "mcp_oauth_connections"
164
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("oauth"))
165
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), unique=True)
166
+ config_key: Mapped[str] = mapped_column(String)
167
+ generation: Mapped[str] = mapped_column(String, default=lambda: new_id("gen"))
168
+ sealed_data: Mapped[str] = mapped_column(Text)
169
+ status: Mapped[str] = mapped_column(String, default="disconnected")
170
+ lease_owner: Mapped[str | None] = mapped_column(String, nullable=True)
171
+ lease_until: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
172
+
173
+
174
+ class MCPOAuthFlow(Base):
175
+ __tablename__ = "mcp_oauth_flows"
176
+ state_hash: Mapped[str] = mapped_column(String, primary_key=True)
177
+ connection_id: Mapped[str] = mapped_column(ForeignKey("mcp_oauth_connections.id", ondelete="CASCADE"))
178
+ browser_hash: Mapped[str] = mapped_column(String)
179
+ admin_hash: Mapped[str] = mapped_column(String)
180
+ generation: Mapped[str] = mapped_column(String)
181
+ sealed_data: Mapped[str] = mapped_column(Text)
182
+ expires_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
183
+
184
+
185
+ class Policy(Base):
186
+ """A standing rule. `agent_id` NULL applies to every agent. `deny` always wins."""
187
+
188
+ __tablename__ = "policies"
189
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("pol"))
190
+ agent_id: Mapped[str | None] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=True)
191
+ pattern: Mapped[str] = mapped_column(String)
192
+ constraints: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
193
+ request_id: Mapped[str | None] = mapped_column(String, nullable=True)
194
+ effect: Mapped[str] = mapped_column(String) # allow | deny
195
+ note: Mapped[str] = mapped_column(Text, default="")
196
+ created_by: Mapped[str] = mapped_column(String, default="ui")
197
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
198
+
199
+
200
+ class AccessRequest(Base):
201
+ __tablename__ = "requests"
202
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("req"))
203
+ agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
204
+ session_id: Mapped[str | None] = mapped_column(String, nullable=True)
205
+ patterns: Mapped[list[Any]] = mapped_column(JSON)
206
+ reason: Mapped[str] = mapped_column(Text, default="")
207
+ detail: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) # e.g. {"command": ...}
208
+ status: Mapped[str] = mapped_column(String, default="pending") # pending|approved|denied|expired
209
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
210
+ expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
211
+ decided_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
212
+ decision_scope: Mapped[str | None] = mapped_column(String, nullable=True) # once|session|ttl|always|deny
213
+ decision_options: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
214
+ decided_via: Mapped[str | None] = mapped_column(String, nullable=True)
215
+ deny_reason: Mapped[str] = mapped_column(Text, default="")
216
+ # Jev's verdict (semantic.py), kept apart from `detail` so nothing a grant binds to changes.
217
+ # none_as_null: "no verdict asked" is SQL NULL, not the JSON value null, so IS NOT NULL finds verdicts.
218
+ semantic: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True), nullable=True)
219
+
220
+ agent: Mapped[Agent] = relationship(lazy="joined")
221
+
222
+
223
+ class Grant(Base):
224
+ """A temporary permission produced by a decision. `once` grants are consumed atomically."""
225
+
226
+ __tablename__ = "grants"
227
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("grt"))
228
+ agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
229
+ pattern: Mapped[str] = mapped_column(String)
230
+ constraints: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
231
+ scope: Mapped[str] = mapped_column(String) # once | session | ttl
232
+ session_id: Mapped[str | None] = mapped_column(String, nullable=True)
233
+ expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
234
+ uses_remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
235
+ request_id: Mapped[str | None] = mapped_column(ForeignKey("requests.id", ondelete="SET NULL"), nullable=True)
236
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
237
+ revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
238
+
239
+ def is_active(self, at: datetime, session_id: str | None) -> bool:
240
+ if self.revoked_at is not None:
241
+ return False
242
+ if self.expires_at is not None and self.expires_at <= at:
243
+ return False
244
+ if self.uses_remaining is not None and self.uses_remaining <= 0:
245
+ return False
246
+ # a session grant is bound to exactly one session id; one without any id is dead
247
+ if self.scope == "session":
248
+ return bool(self.session_id) and self.session_id == session_id
249
+ return True
250
+
251
+
252
+ class LlmKey(Base):
253
+ """A LiteLLM virtual key minted for one approval on one LLM gateway. The key is the lease made
254
+ concrete: it carries the approved models, the grant's lifetime and a hard spend ceiling, and it
255
+ is deleted upstream the moment the grants behind it are revoked. Agents never see it; the
256
+ broker uses it on their behalf."""
257
+
258
+ __tablename__ = "llm_keys"
259
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("lkey"))
260
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"))
261
+ agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
262
+ request_id: Mapped[str | None] = mapped_column(ForeignKey("requests.id", ondelete="SET NULL"), nullable=True)
263
+ grant_ids: Mapped[list[Any]] = mapped_column(JSON, default=list)
264
+ key: Mapped[str] = mapped_column(Text) # the upstream secret; only ever sent to LiteLLM
265
+ key_alias: Mapped[str] = mapped_column(String, default="")
266
+ models: Mapped[list[Any]] = mapped_column(JSON, default=list)
267
+ limits: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) # max_budget, rpm_limit, ...
268
+ expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
269
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
270
+ revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
271
+
272
+ def is_active(self, at: datetime) -> bool:
273
+ return self.revoked_at is None and (self.expires_at is None or self.expires_at > at)
274
+
275
+
276
+ class Nudge(Base):
277
+ """An agent asking the human to come back to the conversation: a question to answer, a choice to
278
+ make, work to look at. It carries no authority and grants nothing; it is a tap on the shoulder,
279
+ kept until the human clears it so the phone and the dashboard agree on what is outstanding."""
280
+
281
+ __tablename__ = "nudges"
282
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("ndg"))
283
+ agent_id: Mapped[str] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"))
284
+ session_id: Mapped[str | None] = mapped_column(String, nullable=True)
285
+ message: Mapped[str] = mapped_column(Text, default="")
286
+ context: Mapped[str] = mapped_column(String, default="") # where the conversation is: host:directory
287
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
288
+ cleared_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
289
+ cleared_via: Mapped[str | None] = mapped_column(String, nullable=True)
290
+
291
+ agent: Mapped[Agent] = relationship(lazy="joined")
292
+
293
+ @property
294
+ def open(self) -> bool:
295
+ return self.cleared_at is None
296
+
297
+
298
+ class AuditEvent(Base):
299
+ __tablename__ = "audit"
300
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
301
+ ts: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
302
+ agent_id: Mapped[str | None] = mapped_column(String, nullable=True)
303
+ agent_name: Mapped[str] = mapped_column(String, default="")
304
+ session_id: Mapped[str | None] = mapped_column(String, nullable=True)
305
+ tool: Mapped[str] = mapped_column(String)
306
+ patterns: Mapped[list[Any]] = mapped_column(JSON, default=list)
307
+ decision: Mapped[str] = mapped_column(String) # allow|deny|locked|pending|error|approved|denied|revoked...
308
+ detail: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
309
+ exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
310
+ duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
311
+
312
+
313
+ class AdminLoginFlow(Base):
314
+ """Short-lived, browser-bound OIDC transaction; consumed before exchanging its code."""
315
+ __tablename__ = "admin_login_flows"
316
+ state_hash: Mapped[str] = mapped_column(String, primary_key=True)
317
+ browser_hash: Mapped[str] = mapped_column(String)
318
+ config_key: Mapped[str] = mapped_column(String)
319
+ data: Mapped[dict[str, Any]] = mapped_column(JSON)
320
+ expires_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
321
+
322
+
323
+ class AdminSession(Base):
324
+ """Opaque browser sessions. Provider access/refresh/ID tokens are never retained."""
325
+ __tablename__ = "admin_sessions"
326
+ token_hash: Mapped[str] = mapped_column(String, primary_key=True)
327
+ config_key: Mapped[str] = mapped_column(String)
328
+ identity: Mapped[dict[str, Any]] = mapped_column(JSON)
329
+ expires_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
330
+
331
+
332
+ class Setting(Base):
333
+ """Key/value store for everything configurable in the UI (notifier config, admin password hash...)."""
334
+
335
+ __tablename__ = "settings"
336
+ key: Mapped[str] = mapped_column(String, primary_key=True)
337
+ value: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
338
+ updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now, onupdate=now)
339
+
340
+
341
+ Index("ix_grants_agent", Grant.agent_id, Grant.revoked_at)
342
+ Index("ix_requests_status", AccessRequest.status, AccessRequest.created_at)
343
+ Index("ix_audit_ts", AuditEvent.ts)
344
+ Index("ix_nudges_open", Nudge.cleared_at, Nudge.created_at)
345
+ Index("ix_llm_keys_active", LlmKey.resource_id, LlmKey.revoked_at)
346
+
347
+
348
+ # Inventory describes infrastructure; none of these records confer agent permissions.
349
+ class InventoryAsset(Base):
350
+ __tablename__ = "inventory_assets"
351
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("asset"))
352
+ name: Mapped[str] = mapped_column(String, unique=True)
353
+ kind: Mapped[str] = mapped_column(String, default="physical")
354
+ parent_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_assets.id"), nullable=True)
355
+ site: Mapped[str] = mapped_column(String, default="")
356
+ tags: Mapped[str] = mapped_column(String, default="")
357
+ notes: Mapped[str] = mapped_column(Text, default="")
358
+ updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now, onupdate=now)
359
+
360
+
361
+ class InventoryInterface(Base):
362
+ __tablename__ = "inventory_interfaces"
363
+ __table_args__ = (Index("uq_inventory_interface_name", "asset_id", "name", unique=True),)
364
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("iface"))
365
+ asset_id: Mapped[str] = mapped_column(ForeignKey("inventory_assets.id"))
366
+ name: Mapped[str] = mapped_column(String)
367
+ medium: Mapped[str] = mapped_column(String, default="unknown")
368
+ mac: Mapped[str] = mapped_column(String, default="")
369
+ attached_to_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_assets.id"), nullable=True)
370
+ port: Mapped[str] = mapped_column(String, default="")
371
+
372
+
373
+ class InventorySubnet(Base):
374
+ __tablename__ = "inventory_subnets"
375
+ __table_args__ = (Index("uq_inventory_subnet_scope", "scope", "cidr", unique=True),)
376
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("net"))
377
+ name: Mapped[str] = mapped_column(String)
378
+ cidr: Mapped[str] = mapped_column(String)
379
+ scope: Mapped[str] = mapped_column(String, default="default")
380
+ site: Mapped[str] = mapped_column(String, default="")
381
+ vlan: Mapped[int | None] = mapped_column(Integer, nullable=True)
382
+ gateway: Mapped[str] = mapped_column(String, default="")
383
+ dhcp_start: Mapped[str] = mapped_column(String, default="")
384
+ dhcp_end: Mapped[str] = mapped_column(String, default="")
385
+ notes: Mapped[str] = mapped_column(Text, default="")
386
+
387
+
388
+ class InventoryAllocation(Base):
389
+ __tablename__ = "inventory_allocations"
390
+ __table_args__ = (Index("uq_inventory_address", "subnet_id", "address", unique=True),)
391
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("addr"))
392
+ subnet_id: Mapped[str] = mapped_column(ForeignKey("inventory_subnets.id"))
393
+ interface_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_interfaces.id"), nullable=True)
394
+ address: Mapped[str] = mapped_column(String)
395
+ state: Mapped[str] = mapped_column(String, default="reserved")
396
+ label: Mapped[str] = mapped_column(String, default="")
397
+ notes: Mapped[str] = mapped_column(Text, default="")
398
+ updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now, onupdate=now)
399
+
400
+
401
+ class InventoryResourceLink(Base):
402
+ __tablename__ = "inventory_resource_links"
403
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), primary_key=True)
404
+ asset_id: Mapped[str] = mapped_column(ForeignKey("inventory_assets.id"))
405
+
406
+
407
+ class DiscoveryRun(Base):
408
+ __tablename__ = "inventory_discovery_runs"
409
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("sync"))
410
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"), index=True)
411
+ provider: Mapped[str] = mapped_column(String)
412
+ source_fingerprint: Mapped[str | None] = mapped_column(String, nullable=True)
413
+ started_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
414
+ finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
415
+ status: Mapped[str] = mapped_column(String, default="running")
416
+ count: Mapped[int] = mapped_column(Integer, default=0)
417
+ error: Mapped[str] = mapped_column(String, default="")
418
+
419
+
420
+ class DiscoveryObservation(Base):
421
+ __tablename__ = "inventory_discovery_observations"
422
+ __table_args__ = (Index("uq_inventory_observation", "resource_id", "provider", "external_id", unique=True),)
423
+ id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("obs"))
424
+ resource_id: Mapped[str] = mapped_column(ForeignKey("resources.id", ondelete="CASCADE"))
425
+ provider: Mapped[str] = mapped_column(String)
426
+ external_id: Mapped[str] = mapped_column(String)
427
+ data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
428
+ previous: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
429
+ first_seen: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
430
+ last_seen: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
431
+ changed_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
432
+ run_id: Mapped[str] = mapped_column(ForeignKey("inventory_discovery_runs.id", ondelete="CASCADE"))
433
+
434
+
435
+ class DiscoveryLink(Base):
436
+ __tablename__ = "inventory_discovery_links"
437
+ observation_id: Mapped[str] = mapped_column(
438
+ ForeignKey("inventory_discovery_observations.id", ondelete="CASCADE"), primary_key=True)
439
+ asset_id: Mapped[str] = mapped_column(ForeignKey("inventory_assets.id"), index=True)
440
+ interface_id: Mapped[str | None] = mapped_column(ForeignKey("inventory_interfaces.id"), nullable=True)
441
+ reviewed_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
442
+ reviewed_at: Mapped[datetime] = mapped_column(UTCDateTime, default=now)
@@ -0,0 +1,128 @@
1
+ """Network graph from source observations, with explicit freshness and provenance."""
2
+ from collections import Counter
3
+ from datetime import timedelta
4
+
5
+ from sqlalchemy import select
6
+
7
+ from . import discovery
8
+ from .models import (
9
+ DiscoveryLink,
10
+ DiscoveryObservation,
11
+ DiscoveryRun,
12
+ InventoryAsset,
13
+ InventoryInterface,
14
+ Resource,
15
+ now,
16
+ )
17
+
18
+ REFRESH_SECONDS = 60
19
+ FRESH_SECONDS = 180
20
+
21
+
22
+ def iso(value):
23
+ return value.isoformat() if value else None
24
+
25
+
26
+ async def snapshot(db, source=''):
27
+ resources = list((await db.scalars(select(Resource).order_by(Resource.name))).all())
28
+ resources = [r for r in resources if r.kind == 'mcp' and r.config.get('adapter') == 'omada']
29
+ selected = next((r for r in resources if r.id == source), None) if source else next(iter(resources), None)
30
+ result = {'sources': [{'id': r.id, 'name': r.name} for r in resources],
31
+ 'source': selected.id if selected else '', 'nodes': [], 'edges': [], 'counts': {},
32
+ 'refresh_seconds': REFRESH_SECONDS, 'fresh_seconds': FRESH_SECONDS,
33
+ 'generated_at': iso(now()), 'last_success': None, 'latest_status': '', 'error': '',
34
+ 'fresh': False, 'configuration_changed': False}
35
+ if not selected:
36
+ return result
37
+ recent_runs = select(DiscoveryRun).where(
38
+ DiscoveryRun.resource_id == selected.id, DiscoveryRun.provider == 'omada').order_by(
39
+ DiscoveryRun.started_at.desc(), DiscoveryRun.id.desc()).limit(1)
40
+ latest = await db.scalar(recent_runs)
41
+ success = await db.scalar(recent_runs.where(DiscoveryRun.status == 'succeeded'))
42
+ at = now()
43
+ changed = bool(success and success.source_fingerprint != discovery.source_fingerprint(selected.config))
44
+ fresh = bool(success and success.finished_at and success.finished_at >= at - timedelta(seconds=FRESH_SECONDS)
45
+ and not changed and latest.status != 'failed')
46
+ result.update(last_success=iso(success.finished_at) if success else None,
47
+ latest_status=latest.status if latest else '', error=latest.error if latest else '',
48
+ fresh=fresh, configuration_changed=changed)
49
+ observations = list((await db.scalars(select(DiscoveryObservation).where(
50
+ DiscoveryObservation.resource_id == selected.id, DiscoveryObservation.provider == 'omada'))).all())
51
+ links = {r.observation_id: r for r in (await db.scalars(select(DiscoveryLink))).all()}
52
+ by_identity = {r.external_id: r for r in observations}
53
+ by_asset = {}
54
+ for r in observations:
55
+ if r.id in links:
56
+ by_asset.setdefault(links[r.id].asset_id, []).append(r)
57
+ interfaces = list((await db.scalars(select(InventoryInterface))).all())
58
+ assets = {r.id: r for r in (await db.scalars(select(InventoryAsset))).all()}
59
+ manual_nodes = {}
60
+ for row in sorted(observations, key=lambda r: (r.data.get('name', '').casefold(), r.id)):
61
+ data = row.data
62
+ missing = bool(success and row.run_id != success.id)
63
+ if not fresh:
64
+ state = 'stale'
65
+ elif missing:
66
+ state = 'old' if row.last_seen < at - timedelta(hours=24) else 'missing'
67
+ else:
68
+ state = {'Active': 'online', 'Inactive': 'inactive'}.get(data.get('status'), 'unknown')
69
+ link = links.get(row.id)
70
+ site_id = row.external_id.split('/')[0]
71
+ target = by_identity.get(data.get('attachment', ''))
72
+ node = {'id': row.id, 'name': data.get('name') or data.get('mac') or 'Unnamed device',
73
+ 'site': data.get('site', ''), 'site_id': site_id,
74
+ 'mac': data.get('mac', ''), 'addresses': data.get('addresses', []),
75
+ 'medium': data.get('medium', 'unknown'), 'port': data.get('port', ''),
76
+ 'role': data.get('device_type') or ('network' if data.get('kind') == 'network' else 'client'),
77
+ 'uplink_note': data.get('uplink_note', ''),
78
+ 'model': data.get('model', ''), 'hub': data.get('kind') == 'network',
79
+ 'state': state, 'source_status': data.get('status', 'Unknown'),
80
+ 'missing': missing, 'last_observed': iso(row.last_seen), 'first_observed': iso(row.first_seen),
81
+ 'unreviewed': link is None, 'attachment': target.id if target and target.id != row.id else '',
82
+ 'attachment_label': target.data.get('name', '') if target else data.get('attachment', ''),
83
+ 'attachment_source': 'Omada' if data.get('attachment') else '',
84
+ 'review_url': f'/ui/inventory/discovery/{row.id}/review',
85
+ 'asset_url': f'/ui/inventory/assets/{link.asset_id}' if link else ''}
86
+ result['nodes'].append(node)
87
+ if target and target.id != row.id:
88
+ result['edges'].append({'source': target.id, 'target': row.id, 'medium': node['medium'],
89
+ 'label': node['port'], 'provenance': 'Omada', 'historical': missing})
90
+ elif link and not data.get('attachment'):
91
+ # Only reviewed inventory links can supply a manual uplink; MAC/IP guesses are not topology.
92
+ for iface in interfaces:
93
+ if (iface.asset_id != link.asset_id or not iface.attached_to_id
94
+ or data.get('device_type') == 'ap' and data.get('medium') in ('wifi', 'wired')
95
+ and iface.medium != data['medium']):
96
+ continue
97
+ targets = [r for r in by_asset.get(iface.attached_to_id, [])
98
+ if r.external_id.split('/')[0] == site_id and r.id != row.id]
99
+ if not targets and iface.attached_to_id in assets and iface.attached_to_id not in by_asset:
100
+ # Explicit inventory connections can include an unmanaged WAN, modem, or switch.
101
+ parent = assets[iface.attached_to_id]
102
+ parent_id = f'inventory:{site_id}:{parent.id}'
103
+ manual_nodes[parent_id] = {
104
+ 'id': parent_id, 'name': parent.name, 'site': data.get('site', ''), 'site_id': site_id,
105
+ 'mac': '', 'addresses': [], 'medium': 'unknown', 'port': '',
106
+ 'role': 'external' if parent.kind == 'service' else 'network', 'model': '',
107
+ 'hub': True, 'virtual': True, 'state': 'unknown', 'source_status': 'Not monitored',
108
+ 'missing': False, 'last_observed': None, 'first_observed': None, 'unreviewed': False,
109
+ 'attachment': '', 'attachment_label': '', 'attachment_source': '', 'uplink_note': '',
110
+ 'review_url': '', 'asset_url': f'/ui/inventory/assets/{parent.id}'}
111
+ result['edges'].append({'source': parent_id, 'target': row.id, 'medium': iface.medium,
112
+ 'label': iface.port, 'provenance': 'Inventory', 'historical': False})
113
+ if not node['attachment']:
114
+ node['attachment'], node['attachment_label'] = parent_id, parent.name
115
+ node['attachment_source'] = 'Inventory'
116
+ node['medium'], node['port'] = iface.medium, iface.port
117
+ if len(targets) == 1:
118
+ parent = targets[0]
119
+ result['edges'].append({'source': parent.id, 'target': row.id, 'medium': iface.medium,
120
+ 'label': iface.port, 'provenance': 'Inventory', 'historical': False})
121
+ if not node['attachment']:
122
+ node['attachment'], node['attachment_label'] = parent.id, parent.data.get('name', '')
123
+ node['attachment_source'] = 'Inventory'
124
+ node['medium'], node['port'] = iface.medium, iface.port
125
+ result['counts'] = dict(Counter(n['state'] for n in result['nodes']))
126
+ result['nodes'].extend(manual_nodes.values())
127
+ result['counts']['unreviewed'] = sum(n['unreviewed'] for n in result['nodes'])
128
+ return result
@@ -0,0 +1,8 @@
1
+ """Pluggable notification channels. A driver turns an access request into something a human can
2
+ answer (phone push, chat message, webhook...) and optionally feeds decisions back."""
3
+
4
+ # Importing a driver module registers it. Add new drivers here.
5
+ from . import homeassistant, log, webhook
6
+ from .base import Field, Notifier, ProbeResult, registry
7
+
8
+ __all__ = ["Field", "Notifier", "ProbeResult", "homeassistant", "log", "registry", "webhook"]