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,242 @@
1
+ """Approval friction from request identities and terminal call evidence, with exact-rule proposals."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from collections import Counter
7
+ from datetime import timedelta
8
+ from statistics import median
9
+
10
+ from keywright.patterns import validate
11
+ from sqlalchemy import select
12
+
13
+ from . import audit, policy, policy_preview
14
+ from .access import operation_matches
15
+ from .models import AccessRequest, Agent, AuditEvent, Policy, Resource, now
16
+ from .semantic import SECRET_TEXT
17
+
18
+ ROW_LIMIT = 2000
19
+ MIN_APPROVALS = 3
20
+ HUMAN_CHANNELS = frozenset({"ui", "link", "homeassistant", "webhook", "log"})
21
+ TOOL_LABELS = {"ssh_run": "SSH command", "ssh_get": "File read", "ssh_put": "File write",
22
+ "ssh_capture": "Host-to-host transfer", "mcp_call": "MCP tool", "llm.call": "LLM call",
23
+ "request": "Access request"}
24
+
25
+
26
+ def fingerprint(value) -> str:
27
+ return hashlib.sha256(json.dumps(value, sort_keys=True, allow_nan=False).encode()).hexdigest()
28
+
29
+
30
+ def request_tool(detail) -> str:
31
+ if not isinstance(detail, dict) or "operations" in detail:
32
+ return "request"
33
+ if "server" in detail:
34
+ return "mcp_call"
35
+ if "gateway" in detail:
36
+ return "llm.call"
37
+ if "host" in detail:
38
+ if "to_host" in detail:
39
+ return "ssh_capture"
40
+ if "command" in detail:
41
+ return "ssh_run"
42
+ if "sha256" in detail:
43
+ return "ssh_put"
44
+ if "path" in detail:
45
+ return "ssh_get"
46
+ return "request"
47
+
48
+
49
+ def evidence(tool, detail, resources):
50
+ """Never recover scrubbed fields or expose operation inputs in report projections."""
51
+ try:
52
+ paths, op = policy_preview._operation(AuditEvent(tool=tool, detail=audit._scrub(detail)), resources)
53
+ if not paths or SECRET_TEXT.search(json.dumps(op, sort_keys=True, allow_nan=False)):
54
+ return None
55
+ return paths, op
56
+ except (ValueError, TypeError, KeyError, AttributeError):
57
+ return None
58
+
59
+
60
+ def outcome(event):
61
+ """A permission decision is separate from a result, including allowed calls that failed."""
62
+ detail = event.detail if isinstance(event.detail, dict) else {}
63
+ if event.decision in {"deny", "locked", "refused"}:
64
+ return "blocked"
65
+ if event.decision == "error":
66
+ return "failed"
67
+ if event.decision != "allow":
68
+ return "lifecycle"
69
+ if event.tool == "mcp_call":
70
+ return ("failed" if detail["is_error"] else "succeeded") if type(detail.get("is_error")) is bool else "unknown"
71
+ if type(event.exit_code) is int:
72
+ failed = event.exit_code >= 400 or detail.get("response_complete") is False if event.tool == "llm.call" \
73
+ else event.exit_code != 0
74
+ return "failed" if failed else "succeeded"
75
+ return "unknown"
76
+
77
+
78
+ def _group_key(agent_id, tool, detail, resources, fallback):
79
+ ev = evidence(tool, detail, resources)
80
+ # Missing or scrubbed arguments cannot establish that two operations were identical.
81
+ return (agent_id, tool, fingerprint(ev) if ev else fallback), ev
82
+
83
+
84
+ def _new_group(agent_id, tool, detail, agents, resources):
85
+ detail = detail if isinstance(detail, dict) else {}
86
+ resource = resources.get(detail.get("resource_id")) if isinstance(detail.get("resource_id"), str) else None
87
+ agent = agents.get(agent_id)
88
+ return {"agent_id": agent_id, "agent": agent.name if agent else "Deleted / unknown agent",
89
+ "resource": resource.name if resource else "Unrecorded / removed target",
90
+ "tool": TOOL_LABELS.get(tool, "Access request"), "requests": [], "events": [],
91
+ "statuses": Counter(), "waits": [], "failures": 0, "blocked": 0, "eligible": [],
92
+ "evidence": None, "suggestion": None, "hazard_history": False}
93
+
94
+
95
+ def _eligible(req, ev, at):
96
+ if req.status != "approved" or req.decided_via not in HUMAN_CHANNELS or req.decided_at is None:
97
+ return False
98
+ if not req.created_at <= req.decided_at <= at or req.decision_scope not in {"once", "ttl", "session"}:
99
+ return False
100
+ if not isinstance(req.decision_options, dict) or req.decision_options.get("extent") != "exact" or ev is None:
101
+ return False
102
+ paths, op = ev
103
+ # Keep recorded hazards too, even when the current catalog no longer flags the operation.
104
+ if (req.detail or {}).get("hazards") or op.get("hazards") or any("*" in p for p in paths):
105
+ return False
106
+ # A partially approved request may omit paths; don't broaden it to the whole operation.
107
+ return (isinstance(req.patterns, list) and all(isinstance(p, str) for p in req.patterns)
108
+ and set(req.patterns) == set(paths))
109
+
110
+
111
+ def _proposal(group, agents, resources, policies, at):
112
+ if (len(group["eligible"]) < MIN_APPROVALS or group["statuses"]["denied"]
113
+ or group["failures"] or group["blocked"] or group["hazard_history"]):
114
+ return None
115
+ agent = agents.get(group["agent_id"])
116
+ if agent is None or agent.disabled:
117
+ return None
118
+ paths, op = group["evidence"]
119
+ constraints = {k: v for k, v in op.items() if k != "hazards"}
120
+ applicable = [p for p in policies if p.agent_id in (None, agent.id)]
121
+ state = {"resources": {r.path_prefix: r for r in resources.values()}, "requests": {},
122
+ "grants": [], "at": at, "operation": op}
123
+ # Ignore temporary grants here: an existing lease must not hide whether standing access asks.
124
+ before, _ = policy.decide(agent, None, paths, policies=applicable, **state)
125
+ if before.status != "pending":
126
+ return None
127
+ source = group["eligible"][0]
128
+ try:
129
+ paths = [validate(p) for p in paths]
130
+ except ValueError:
131
+ return None
132
+ rules = [Policy(id=f"preview-{i}", agent_id=agent.id, pattern=p, effect="allow",
133
+ constraints=constraints, request_id=source.id, created_by="ui",
134
+ note="Exact operation reviewed in approval friction report") for i, p in enumerate(paths)]
135
+ after, _ = policy.decide(agent, None, paths, policies=[*applicable, *rules], **state)
136
+ if not after.allowed:
137
+ return None
138
+ return {"request_id": source.id, "agent": agent.name, "resource": group["resource"],
139
+ "tool": group["tool"], "support": len(group["eligible"]),
140
+ "examples": [r.id for r in group["eligible"][:5]], "rules": rules,
141
+ # Digest only: raw constraints and resource credentials never enter the review token.
142
+ "fingerprint": fingerprint({"agent": agent.id, "paths": paths, "operation": constraints,
143
+ "support": [r.id for r in group["eligible"]],
144
+ "targets": [(r.id, r.kind, r.name, r.config) for r in resources.values()
145
+ if r.id in (op.get("resource_id"), op.get("to_resource_id"))],
146
+ "policies": [(p.id, p.pattern, p.effect, p.constraints) for p in applicable]})}
147
+
148
+
149
+ async def report(db, start, end, *, agent_id=""):
150
+ at = now()
151
+ if end <= start or end - start > timedelta(days=90):
152
+ raise ValueError("Choose a date range of 1 to 90 days.")
153
+ req_query = select(AccessRequest).where(AccessRequest.created_at >= start, AccessRequest.created_at < end)
154
+ ev_query = select(AuditEvent).where(AuditEvent.ts >= start, AuditEvent.ts < end,
155
+ AuditEvent.tool.in_(policy_preview.CALL_TOOLS))
156
+ if agent_id:
157
+ req_query = req_query.where(AccessRequest.agent_id == agent_id)
158
+ ev_query = ev_query.where(AuditEvent.agent_id == agent_id)
159
+ requests = list((await db.scalars(req_query.order_by(AccessRequest.created_at.desc(), AccessRequest.id)
160
+ .limit(ROW_LIMIT + 1))).all())
161
+ events = list((await db.scalars(ev_query.order_by(AuditEvent.ts.desc(), AuditEvent.id.desc())
162
+ .limit(ROW_LIMIT + 1))).all())
163
+ limited = len(requests) > ROW_LIMIT or len(events) > ROW_LIMIT
164
+ agents = {a.id: a for a in (await db.scalars(select(Agent).order_by(Agent.name))).all()}
165
+ resources = {r.id: r for r in (await db.scalars(select(Resource))).all()}
166
+ policies = list((await db.scalars(select(Policy).order_by(Policy.id))).all())
167
+ groups, totals, coverage = {}, Counter(), Counter()
168
+ waits = []
169
+ for req in requests[:ROW_LIMIT]:
170
+ tool = request_tool(req.detail)
171
+ key, ev = _group_key(req.agent_id, tool, req.detail, resources, "request:" + req.id)
172
+ group = groups.setdefault(key, _new_group(req.agent_id, tool, req.detail, agents, resources))
173
+ group["evidence"] = ev
174
+ if isinstance(req.detail, dict) and req.detail.get("hazards"):
175
+ group["hazard_history"] = True
176
+ status = req.status
177
+ if status == "pending" and req.expires_at is not None and req.expires_at <= at:
178
+ status = "expired"
179
+ if status == "approved":
180
+ status = "human_approved" if req.decided_via in HUMAN_CHANNELS else "other_approved"
181
+ group["statuses"][status] += 1
182
+ totals[status] += 1
183
+ totals["requests"] += 1
184
+ group["requests"].append({"id": req.id, "status": status, "at": req.created_at})
185
+ if req.status in {"approved", "denied"} and req.decided_via in HUMAN_CHANNELS:
186
+ if req.decided_at is not None and req.created_at <= req.decided_at <= at:
187
+ seconds = (req.decided_at - req.created_at).total_seconds()
188
+ group["waits"].append(seconds); waits.append(seconds)
189
+ else:
190
+ coverage["missing_wait"] += 1
191
+ if ev is None:
192
+ coverage["incomplete_requests"] += 1
193
+ if _eligible(req, ev, at):
194
+ group["eligible"].append(req)
195
+ for event in events[:ROW_LIMIT]:
196
+ result = outcome(event)
197
+ totals[result] += 1
198
+ totals["call_records"] += 1
199
+ if result not in {"failed", "blocked"}:
200
+ continue
201
+ key, ev = _group_key(event.agent_id, event.tool, event.detail, resources, "audit:" + str(event.id))
202
+ group = groups.setdefault(key, _new_group(event.agent_id, event.tool, event.detail, agents, resources))
203
+ group["failures" if result == "failed" else "blocked"] += 1
204
+ group["events"].append({"id": event.id, "status": result, "at": event.ts})
205
+ if ev is None:
206
+ coverage["incomplete_events"] += 1
207
+ proposals = {}
208
+ for group in groups.values():
209
+ group["median_wait"] = round(median(group["waits"]), 1) if group["waits"] else None
210
+ if not limited and group["evidence"] is not None:
211
+ proposal = _proposal(group, agents, resources, policies, at)
212
+ if proposal:
213
+ proposals[proposal["request_id"]] = proposal
214
+ group["suggestion"] = {k: proposal[k] for k in ("request_id", "support", "examples")}
215
+ # Strip private inputs before returning report data to templates.
216
+ del group["evidence"], group["eligible"], group["waits"]
217
+ rows = sorted(groups.values(), key=lambda g: (len(g["requests"]) + g["failures"] + g["blocked"]), reverse=True)
218
+ return {"rows": rows, "totals": totals, "coverage": coverage, "proposals": proposals,
219
+ "agents": list(agents.values()), "median_wait": round(median(waits), 1) if waits else None,
220
+ "wait_samples": len(waits), "limited": limited, "limit": ROW_LIMIT,
221
+ "at": at, "start": start, "end": end}
222
+
223
+
224
+ async def save_proposal(db, proposal):
225
+ """Persist only the freshly reconstructed exact rules; caller must verify its review token."""
226
+ added = 0
227
+ for candidate in proposal["rules"]:
228
+ current = list((await db.scalars(select(Policy).where(
229
+ Policy.agent_id == candidate.agent_id, Policy.pattern == candidate.pattern,
230
+ Policy.effect == "allow"))).all())
231
+ if any(operation_matches(p.constraints, candidate.constraints) for p in current):
232
+ continue
233
+ db.add(Policy(agent_id=candidate.agent_id, pattern=candidate.pattern, effect="allow",
234
+ constraints=candidate.constraints, request_id=candidate.request_id,
235
+ created_by="ui", note=candidate.note))
236
+ added += 1
237
+ await audit.record(db, tool="policy.suggestion", decision="created",
238
+ detail={"source_request_id": proposal["request_id"], "rules_added": added,
239
+ "agent_id": proposal["rules"][0].agent_id,
240
+ "support": proposal["support"], "extent": "exact"}, commit=False)
241
+ await db.commit()
242
+ return added
@@ -0,0 +1,64 @@
1
+ """Resource setup catalog. Presets supply defaults; adapters and OAuth remain reusable."""
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Integration:
7
+ id: str
8
+ title: str
9
+ description: str
10
+ badge: str
11
+ icon: str
12
+ kind: str
13
+ adapter: str = ""
14
+ default_name: str = ""
15
+ url: str = ""
16
+ auth_type: str = "headers"
17
+ instructions: str = ""
18
+ keywords: str = ""
19
+
20
+ @property
21
+ def search_text(self) -> str:
22
+ return f"{self.title} {self.description} {self.badge} {self.keywords}".lower()
23
+
24
+
25
+ PRESETS = (
26
+ Integration(
27
+ "notion", "Notion", "Pages, databases, and workspace search.", "Browser sign-in", "notion", "mcp",
28
+ default_name="Notion", url="https://mcp.notion.com/mcp", auth_type="oauth",
29
+ instructions="Name this connection, then continue to Notion to sign in and choose your workspace.",
30
+ keywords="notes documents oauth cloud",
31
+ ),
32
+ Integration(
33
+ "omada", "TP-Link Omada", "Sites, network devices, and connected clients.", "App credentials", "omada", "mcp",
34
+ adapter="omada", default_name="Omada",
35
+ instructions="Create an Open API app in your controller, then enter its address and app credentials below.",
36
+ keywords="network wifi switches access points controller",
37
+ ),
38
+ Integration(
39
+ "keywright", "Keywright inventory", "Hosts, topology, subnets, and IP allocations.", "Built in", "shield", "mcp",
40
+ adapter="keywright", default_name="Keywright",
41
+ instructions="Add Keywright's inventory tools for your agents. No account or credentials are needed.",
42
+ keywords="local infrastructure devices",
43
+ ),
44
+ )
45
+ GENERICS = (
46
+ Integration("ssh", "SSH host", "Connect a server by its address and SSH account.", "SSH key", "ssh", "ssh"),
47
+ Integration("mcp", "MCP server", "Use any Streamable HTTP server with OAuth or headers.", "Custom connection", "mcp", "mcp"),
48
+ Integration("llm", "LLM gateway", "Connect a LiteLLM gateway with models and spending limits.", "API key", "llm", "llm"),
49
+ )
50
+ PRESET_BY_ID = {item.id: item for item in PRESETS}
51
+
52
+
53
+ def for_resource(resource) -> Integration | None:
54
+ config = resource.config or {}
55
+ if resource.kind != "mcp":
56
+ return None
57
+ if preset := PRESET_BY_ID.get(config.get("integration")):
58
+ return preset
59
+ if preset := PRESET_BY_ID.get(config.get("adapter")):
60
+ return preset
61
+ # Recognize Notion connections created before the catalog existed.
62
+ if config.get("url", "").rstrip("/") == PRESET_BY_ID["notion"].url and config.get("auth_type") == "oauth":
63
+ return PRESET_BY_ID["notion"]
64
+ return None