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,156 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, ClassVar
8
+
9
+ from .. import semantic
10
+ from ..audit import _scrub
11
+ from ..models import AccessRequest, Agent, Nudge
12
+
13
+
14
+ @dataclass
15
+ class Field:
16
+ """One input on the driver's settings form. Rendered generically by the admin UI."""
17
+
18
+ name: str
19
+ label: str
20
+ type: str = "text" # text | password | url | textarea | select | multiselect | checkbox
21
+ help: str = ""
22
+ required: bool = False
23
+ options: list[tuple[str, str]] = field(default_factory=list) # (value, label) for selects
24
+ options_from_probe: str = "" # key in ProbeResult.choices to populate options dynamically
25
+
26
+
27
+ @dataclass
28
+ class ProbeResult:
29
+ ok: bool
30
+ message: str = ""
31
+ info: dict[str, Any] = field(default_factory=dict)
32
+ choices: dict[str, list[tuple[str, str]]] = field(default_factory=dict) # field name -> options
33
+
34
+
35
+ DecisionCallback = Callable[[str], Awaitable[None]] # receives a signed decision token
36
+
37
+
38
+ class Notifier(ABC):
39
+ id: str = ""
40
+ label: str = ""
41
+ description: str = ""
42
+ fields: ClassVar[list[Field]] = []
43
+ supports_actions: bool = False # can render decision buttons (vs. links only)
44
+ max_actions: int = 3
45
+
46
+ def __init__(self, config: dict[str, Any]):
47
+ self.config = config
48
+
49
+ @abstractmethod
50
+ async def probe(self) -> ProbeResult:
51
+ """Validate the config and discover selectable targets. Never raises."""
52
+
53
+ @abstractmethod
54
+ async def send(self, req: AccessRequest, agent: Agent, links: dict[str, str], buttons: list[str]) -> None:
55
+ """Deliver the request. `links` maps scope -> signed decision URL (temporary approval scopes and deny).
56
+ `buttons` is the ordered subset of scopes to render as actions when supported."""
57
+
58
+ def capped(self, buttons: list[str]) -> list[str]:
59
+ """At most `max_actions` buttons, deny last and never the one that is dropped."""
60
+ allow = [b for b in buttons if b != "deny"]
61
+ if "deny" in buttons:
62
+ return [*allow[:self.max_actions - 1], "deny"]
63
+ return allow[:self.max_actions]
64
+
65
+ async def clear(self, req: AccessRequest) -> None:
66
+ """Called after a decision; drivers may retract the notification."""
67
+
68
+ async def send_nudge(self, nudge: Nudge, agent: Agent, url: str) -> None:
69
+ """Deliver "the agent needs you in the conversation". No decision comes back: the human
70
+ goes to the conversation. `url` opens the dashboard, where the nudge can also be cleared."""
71
+ raise NotImplementedError
72
+
73
+ async def clear_nudge(self, nudge: Nudge) -> None:
74
+ """Called once the human has cleared it; drivers may retract the notification."""
75
+
76
+ async def start(self, on_decision: DecisionCallback) -> None:
77
+ """Optional: start a background listener that feeds decisions back."""
78
+
79
+ async def stop(self) -> None:
80
+ """Optional: stop the background listener."""
81
+
82
+
83
+ registry: dict[str, type[Notifier]] = {}
84
+
85
+
86
+ def register(cls: type[Notifier]) -> type[Notifier]:
87
+ registry[cls.id] = cls
88
+ return cls
89
+
90
+
91
+ def request_summary(req: AccessRequest, agent: Agent) -> tuple[str, str]:
92
+ from keywright.hazards import blocks, describe
93
+
94
+ from ..access import binds_exactly, hazard_level, hazards_of, operations, path_scoped
95
+
96
+ level = hazard_level(req.detail)
97
+ title = f"Keywright DESTRUCTIVE ({level}): {agent.name}" if blocks(level) else f"Keywright: {agent.name}"
98
+ groups = (req.detail or {}).get("bundles") or {}
99
+ grouped = {p for ps in groups.values() for p in ps}
100
+ lines = [f"bundle {name} ({len(ps)} patterns)" for name, ps in groups.items()]
101
+ lines += [p for p in req.patterns if p not in grouped]
102
+ pats = "\n".join(lines)
103
+ body = f"{pats}\n{req.reason}".strip()
104
+ if level:
105
+ body = f"FLAGGED {level}: {describe(hazards_of(req.detail))}\n" + body
106
+ jev = semantic.describe(getattr(req, "semantic", None))
107
+ if jev:
108
+ body += "\n" + jev
109
+ ops = [op for _, op in operations(req.detail)]
110
+ operation = "\n".join(operation_line(op) for op in ops)
111
+ if operation:
112
+ body += "\n" + operation[:300]
113
+ if len(operation) > 300:
114
+ body += "\n[Preview shortened. Open the request to review full details.]"
115
+ users = {op.get("user") for op in ops if op.get("user")}
116
+ if users:
117
+ body += f"\nRemote user: {', '.join(sorted(users))}"
118
+ if blocks(level):
119
+ body += "\nScope: this exact operation, once. Timed and standing access are not offered."
120
+ elif len(ops) > 1:
121
+ body += f"\nScope: these {len(ops)} operations exactly, each for every duration."
122
+ elif path_scoped(req.detail):
123
+ body += ("\nScope: these exact contents for every duration; 'This path, 15 min' allows any contents at "
124
+ "this path for 15 minutes.")
125
+ elif binds_exactly(req.detail):
126
+ body += "\nScope: exact operation for every duration. Open the request to approve broader patterns."
127
+ else:
128
+ body += "\nScope: the listed permission patterns."
129
+ return title, body
130
+
131
+
132
+ def nudge_summary(nudge: Nudge, agent: Agent) -> tuple[str, str]:
133
+ """Title and body for a nudge, in the shape the phone shows it."""
134
+ title = f"Keywright: {agent.name} needs you"
135
+ lines = [nudge.message]
136
+ where = " \u00b7 ".join(x for x in (nudge.context, nudge.session_id) if x)
137
+ if where:
138
+ lines.append(where)
139
+ return title, "\n".join(lines)
140
+
141
+
142
+ def operation_line(op: dict) -> str:
143
+ """One line per operation, as the phone shows it: a command line, a file write as path, size and
144
+ digest (never the contents), a file read as its path, or a tool call's arguments."""
145
+ if op.get("command"):
146
+ if op.get("to_path"):
147
+ how = "append to" if op.get("append") else "write"
148
+ return f"$ {op['command']}\n output -> {how} {op.get('to_host')}:{op['to_path']} (not returned to the agent)"
149
+ return f"$ {op['command']}"
150
+ if "sha256" in op:
151
+ return f"put {op.get('path')} ({op.get('bytes', '?')} bytes, sha256 {str(op['sha256'])[:12]})"
152
+ if "path" in op:
153
+ return f"get {op['path']}"
154
+ if "arguments" in op:
155
+ return json.dumps(_scrub(op["arguments"]), ensure_ascii=False)
156
+ return ""
@@ -0,0 +1,221 @@
1
+ """Home Assistant driver.
2
+
3
+ Sends actionable notifications through any `notify.*` service HA exposes (the companion app's
4
+ `notify.mobile_app_*` services are the useful ones) and listens on HA's websocket for
5
+ `mobile_app_notification_action` events so a tap on the phone becomes a decision without any
6
+ HA automation. Everything is discovered from the HA instance with a long-lived access token.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import json
14
+ import logging
15
+ from typing import Any, ClassVar
16
+
17
+ import httpx
18
+ import websockets
19
+
20
+ from ..models import AccessRequest, Agent, Nudge
21
+ from .base import (
22
+ DecisionCallback,
23
+ Field,
24
+ Notifier,
25
+ ProbeResult,
26
+ nudge_summary,
27
+ register,
28
+ request_summary,
29
+ )
30
+
31
+ log = logging.getLogger("keywright.notify.ha")
32
+ ACTION_PREFIX = "GK:"
33
+
34
+
35
+ @register
36
+ class HomeAssistantNotifier(Notifier):
37
+ id = "homeassistant"
38
+ label = "Home Assistant (companion app)"
39
+ description = "Actionable push via notify.mobile_app_* services; taps come back over the HA websocket."
40
+ supports_actions = True
41
+ max_actions = 3 # Android caps at 3 buttons; iOS allows more but 3 keeps both consistent
42
+ fields: ClassVar[list[Field]] = [
43
+ Field("base_url", "Home Assistant URL", "url", required=True,
44
+ help="e.g. http://homeassistant.local:8123 or your Nabu Casa URL"),
45
+ Field("token", "Long-lived access token", "password", required=True,
46
+ help="Profile -> Security -> Long-lived access tokens. Stored in the Keywright database."),
47
+ Field("targets", "Notify services", "multiselect", options_from_probe="targets",
48
+ help="Probe first, then pick the phones to notify."),
49
+ Field("critical", "Mark as time-sensitive / high priority", "checkbox"),
50
+ ]
51
+
52
+ def __init__(self, config: dict[str, Any]):
53
+ super().__init__(config)
54
+ self._task: asyncio.Task | None = None
55
+ self._stop = asyncio.Event()
56
+
57
+ # ---- helpers ----------------------------------------------------------------------------
58
+ def _base(self) -> str:
59
+ return self.config.get("base_url", "").rstrip("/")
60
+
61
+ def _headers(self) -> dict[str, str]:
62
+ return {"Authorization": f"Bearer {self.config.get('token', '')}", "Content-Type": "application/json"}
63
+
64
+ def _ws_url(self) -> str:
65
+ b = self._base()
66
+ return b.replace("https://", "wss://", 1).replace("http://", "ws://", 1) + "/api/websocket"
67
+
68
+ # ---- Notifier API -----------------------------------------------------------------------
69
+ async def probe(self) -> ProbeResult:
70
+ if not self._base() or not self.config.get("token"):
71
+ return ProbeResult(ok=False, message="URL and token are required.")
72
+ try:
73
+ async with httpx.AsyncClient(timeout=10, headers=self._headers()) as c:
74
+ cfg = await c.get(self._base() + "/api/config")
75
+ if cfg.status_code == 401:
76
+ return ProbeResult(ok=False, message="HA rejected the token (401).")
77
+ cfg.raise_for_status()
78
+ svc = await c.get(self._base() + "/api/services")
79
+ svc.raise_for_status()
80
+ except httpx.HTTPError as e:
81
+ return ProbeResult(ok=False, message=f"Could not reach Home Assistant: {e}")
82
+ info = cfg.json()
83
+ notify = next((d for d in svc.json() if d.get("domain") == "notify"), {})
84
+ services = notify.get("services", {}) or {}
85
+ targets: list[tuple[str, str]] = []
86
+ for name, meta in sorted(services.items()):
87
+ if name in ("send_message", "persistent_notification"):
88
+ continue
89
+ label = (meta or {}).get("name") or name
90
+ if name.startswith("mobile_app_"):
91
+ label = f"{label} (companion app)"
92
+ targets.append((name, label))
93
+ msg = f"Connected to {info.get('location_name', 'Home Assistant')} {info.get('version', '')}: " \
94
+ f"{len(targets)} notify services."
95
+ return ProbeResult(ok=True, message=msg, info={"location_name": info.get("location_name"),
96
+ "version": info.get("version")},
97
+ choices={"targets": targets})
98
+
99
+ async def send(self, req: AccessRequest, agent: Agent, links: dict[str, str], buttons: list[str]) -> None:
100
+ targets = self.config.get("targets") or []
101
+ if not targets:
102
+ raise RuntimeError("Home Assistant driver has no notify targets selected")
103
+ title, body = request_summary(req, agent)
104
+ from ..approvals import SCOPE_LABELS, notification_action
105
+
106
+ # Compact action IDs are authenticated; knowledge of a request ID grants no authority.
107
+ actions = [
108
+ {"action": notification_action(req.id, s), "title": SCOPE_LABELS[s], "destructive": s == "deny"}
109
+ for s in self.capped(buttons)
110
+ ]
111
+ open_url = links.get("once", "").rsplit("/d/", 1)[0] + f"/ui/requests/{req.id}"
112
+ data: dict[str, Any] = {
113
+ "tag": req.id,
114
+ "group": "keywright",
115
+ "actions": actions,
116
+ "url": open_url,
117
+ "clickAction": open_url,
118
+ # Android: survive swipes and stay put until decided or expired (the server clears it).
119
+ "persistent": True,
120
+ "sticky": True,
121
+ # iOS: show it even while the companion app is in the foreground.
122
+ "presentation_options": ["alert", "sound", "badge"],
123
+ }
124
+ if self.config.get("critical"):
125
+ data["push"] = {"interruption-level": "time-sensitive", "sound": "default"}
126
+ data["ttl"] = 0
127
+ data["priority"] = "high"
128
+ payload = {"title": title, "message": body, "data": data}
129
+ async with httpx.AsyncClient(timeout=15, headers=self._headers()) as c:
130
+ for t in targets:
131
+ r = await c.post(f"{self._base()}/api/services/notify/{t}", json=payload)
132
+ if r.status_code >= 400:
133
+ log.error("HA notify.%s failed: %s %s", t, r.status_code, r.text[:200])
134
+
135
+ async def send_nudge(self, nudge: Nudge, agent: Agent, url: str) -> None:
136
+ targets = self.config.get("targets") or []
137
+ if not targets:
138
+ raise RuntimeError("Home Assistant driver has no notify targets selected")
139
+ from ..nudges import dismiss_action
140
+
141
+ title, body = nudge_summary(nudge, agent)
142
+ # Nothing here is a decision, so the only button clears the card; tapping the body opens
143
+ # the dashboard. Not marked critical even when approvals are: a question can wait for the
144
+ # next time they look at their phone, a destructive command cannot.
145
+ data: dict[str, Any] = {
146
+ "tag": nudge.id,
147
+ "group": "keywright-attention",
148
+ "actions": [{"action": dismiss_action(nudge.id), "title": "Dismiss"}],
149
+ "url": url,
150
+ "clickAction": url,
151
+ "persistent": True,
152
+ "sticky": True,
153
+ "presentation_options": ["alert", "sound", "badge"],
154
+ }
155
+ payload = {"title": title, "message": body, "data": data}
156
+ async with httpx.AsyncClient(timeout=15, headers=self._headers()) as c:
157
+ for t in targets:
158
+ r = await c.post(f"{self._base()}/api/services/notify/{t}", json=payload)
159
+ if r.status_code >= 400:
160
+ log.error("HA notify.%s failed: %s %s", t, r.status_code, r.text[:200])
161
+
162
+ async def clear(self, req: AccessRequest) -> None:
163
+ await self._clear_tag(req.id)
164
+
165
+ async def clear_nudge(self, nudge: Nudge) -> None:
166
+ await self._clear_tag(nudge.id)
167
+
168
+ async def _clear_tag(self, tag: str) -> None:
169
+ payload = {"message": "clear_notification", "data": {"tag": tag}}
170
+ with contextlib.suppress(Exception):
171
+ async with httpx.AsyncClient(timeout=10, headers=self._headers()) as c:
172
+ for t in self.config.get("targets") or []:
173
+ await c.post(f"{self._base()}/api/services/notify/{t}", json=payload)
174
+
175
+ async def start(self, on_decision: DecisionCallback) -> None:
176
+ self._stop.clear()
177
+ self._task = asyncio.create_task(self._listen(on_decision), name="ha-ws-listener")
178
+
179
+ async def stop(self) -> None:
180
+ self._stop.set()
181
+ if self._task:
182
+ self._task.cancel()
183
+ with contextlib.suppress(asyncio.CancelledError, Exception):
184
+ await self._task
185
+ self._task = None
186
+
187
+ async def _listen(self, on_decision: DecisionCallback) -> None:
188
+ backoff = 2
189
+ while not self._stop.is_set():
190
+ try:
191
+ async with websockets.connect(self._ws_url(), max_size=2**20) as ws:
192
+ hello = json.loads(await ws.recv())
193
+ if hello.get("type") != "auth_required":
194
+ raise RuntimeError(f"unexpected hello {hello}")
195
+ await ws.send(json.dumps({"type": "auth", "access_token": self.config.get("token", "")}))
196
+ resp = json.loads(await ws.recv())
197
+ if resp.get("type") != "auth_ok":
198
+ log.error("HA websocket auth failed: %s", resp)
199
+ await asyncio.sleep(60)
200
+ continue
201
+ await ws.send(json.dumps({"id": 1, "type": "subscribe_events",
202
+ "event_type": "mobile_app_notification_action"}))
203
+ log.info("HA websocket listener connected to %s", self._base())
204
+ backoff = 2
205
+ async for raw in ws:
206
+ msg = json.loads(raw)
207
+ if msg.get("type") != "event":
208
+ continue
209
+ action = ((msg.get("event") or {}).get("data") or {}).get("action") or ""
210
+ log.info("HA notification action received: %s", action[:24] + ("…" if len(action) > 24 else ""))
211
+ if action.startswith((ACTION_PREFIX, "KW_", "KWN_")):
212
+ try:
213
+ await on_decision(action)
214
+ except Exception:
215
+ log.exception("decision callback failed")
216
+ except asyncio.CancelledError:
217
+ raise
218
+ except Exception as e: # noqa: BLE001
219
+ log.warning("HA websocket listener error: %s; retry in %ss", e, backoff)
220
+ await asyncio.sleep(backoff)
221
+ backoff = min(backoff * 2, 60)
@@ -0,0 +1,30 @@
1
+ """Fallback driver: writes requests to the log. Decisions come from the admin UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import ClassVar
7
+
8
+ from ..models import AccessRequest, Agent, Nudge
9
+ from .base import Notifier, ProbeResult, nudge_summary, register, request_summary
10
+
11
+ log = logging.getLogger("keywright.notify")
12
+
13
+
14
+ @register
15
+ class LogNotifier(Notifier):
16
+ id = "log"
17
+ label = "Log only (decide in the admin UI)"
18
+ description = "No push channel. Pending requests show on the dashboard."
19
+ fields: ClassVar[list] = []
20
+
21
+ async def probe(self) -> ProbeResult:
22
+ return ProbeResult(ok=True, message="Nothing to configure.")
23
+
24
+ async def send(self, req: AccessRequest, agent: Agent, links: dict[str, str], buttons: list[str]) -> None:
25
+ title, body = request_summary(req, agent)
26
+ log.warning("ACCESS REQUEST %s\n%s\n%s\ndecide: %s", req.id, title, body, links.get("once", "").rsplit("/", 1)[0])
27
+
28
+ async def send_nudge(self, nudge: Nudge, agent: Agent, url: str) -> None:
29
+ title, body = nudge_summary(nudge, agent)
30
+ log.warning("NEEDS YOU %s\n%s\n%s\nclear: %s", nudge.id, title, body, url)
@@ -0,0 +1,131 @@
1
+ """Owns the active notifier. Config lives in settings["notifications"] =
2
+ {"driver": "<id>", "config": {...}, "buttons": ["once", "session", "deny"]}."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import logging
8
+ from typing import Any
9
+
10
+ from .. import audit
11
+ from ..db import session
12
+ from ..models import AccessRequest, Agent, Nudge
13
+ from ..settings_store import get as get_setting
14
+ from . import registry
15
+ from .base import Notifier
16
+
17
+ log = logging.getLogger("keywright.notify")
18
+ DEFAULT_BUTTONS = ["once", "session", "deny"]
19
+
20
+
21
+ class NotifierManager:
22
+ def __init__(self) -> None:
23
+ self.active: Notifier | None = None
24
+ self.driver_id: str = "log"
25
+ self.buttons: list[str] = list(DEFAULT_BUTTONS)
26
+ self.last_error: str = ""
27
+
28
+ async def reload(self) -> None:
29
+ async with session() as db:
30
+ cfg = await get_setting(db, "notifications", {"driver": "log", "config": {}, "buttons": DEFAULT_BUTTONS})
31
+ await self._apply(cfg)
32
+
33
+ async def _apply(self, cfg: dict[str, Any]) -> None:
34
+ from ..approvals import PHONE_SCOPES
35
+ if self.active:
36
+ await self.active.stop()
37
+ self.driver_id = cfg.get("driver") or "log"
38
+ cls = registry.get(self.driver_id) or registry["log"]
39
+ self.active = cls(cfg.get("config") or {})
40
+ self.buttons = [b for b in (cfg.get("buttons") or DEFAULT_BUTTONS) if b in PHONE_SCOPES]
41
+ if "deny" not in self.buttons:
42
+ self.buttons.append("deny")
43
+ await self.active.start(self.on_decision_token)
44
+ log.info("notifier: %s", self.driver_id)
45
+
46
+ async def stop(self) -> None:
47
+ if self.active:
48
+ await self.active.stop()
49
+
50
+ async def notify_request(self, req: AccessRequest, agent: Agent) -> None:
51
+ if not self.active:
52
+ return
53
+ from ..approvals import decision_links, offered_scopes
54
+
55
+ scopes = offered_scopes(req)
56
+ links = decision_links(req.id, scopes)
57
+ try:
58
+ await self.active.send(req, agent, links, [b for b in self.buttons if b in scopes])
59
+ self.last_error = ""
60
+ except Exception as e:
61
+ self.last_error = f"{type(e).__name__}: {e}"
62
+ log.exception("notifier %s failed for %s", self.driver_id, req.id)
63
+ async with session() as db:
64
+ await audit.record(db, tool="notify", decision="error", agent=agent, patterns=list(req.patterns),
65
+ detail={"request_id": req.id, "driver": self.driver_id, "error": self.last_error})
66
+
67
+ async def request_decided(self, req: AccessRequest) -> None:
68
+ if self.active:
69
+ await self.active.clear(req)
70
+
71
+ async def notify_nudge(self, nudge: Nudge, agent: Agent) -> None:
72
+ """Push "the agent needs you". A driver that cannot carry one just logs it: the dashboard
73
+ card is created either way, so the ask is never lost because a channel is misconfigured."""
74
+ if not self.active:
75
+ return
76
+ from ..approvals import public_url
77
+
78
+ try:
79
+ await self.active.send_nudge(nudge, agent, f"{public_url()}/ui")
80
+ self.last_error = ""
81
+ except NotImplementedError:
82
+ log.info("notifier %s cannot carry nudges; %s is on the dashboard only", self.driver_id, nudge.id)
83
+ except Exception as e:
84
+ self.last_error = f"{type(e).__name__}: {e}"
85
+ log.exception("notifier %s failed for nudge %s", self.driver_id, nudge.id)
86
+ async with session() as db:
87
+ await audit.record(db, tool="notify", decision="error", agent=agent,
88
+ detail={"nudge_id": nudge.id, "driver": self.driver_id, "error": self.last_error})
89
+
90
+ async def nudge_cleared(self, nudge: Nudge) -> None:
91
+ if self.active:
92
+ with contextlib.suppress(Exception):
93
+ await self.active.clear_nudge(nudge)
94
+
95
+ async def on_decision_token(self, action: str) -> None:
96
+ """Accept authenticated compact actions or legacy signed tokens, never bare request IDs."""
97
+ from .. import nudges
98
+ from ..approvals import decide, parse_decision_token, parse_notification_action
99
+
100
+ if action.startswith(nudges.DISMISS):
101
+ nudge_id = nudges.parse_dismiss_action(action)
102
+ if not nudge_id:
103
+ log.warning("ignored unrecognised dismiss action from %s", self.driver_id)
104
+ return
105
+ async with session() as db:
106
+ await nudges.clear(db, nudge_id, via=self.driver_id)
107
+ log.info("nudge %s dismissed via %s", nudge_id, self.driver_id)
108
+ return
109
+ parsed = None
110
+ if action.startswith(("KW_", "GK_")):
111
+ parsed = parse_notification_action(action)
112
+ elif action.startswith("GK:"):
113
+ parsed = parse_decision_token(action[3:])
114
+ else:
115
+ parsed = parse_decision_token(action)
116
+ if not parsed:
117
+ log.warning("ignored unrecognised decision action from %s", self.driver_id)
118
+ return
119
+ request_id, scope = parsed
120
+ async with session() as db:
121
+ try:
122
+ req = await decide(db, request_id, scope, via=self.driver_id)
123
+ except (LookupError, ValueError) as e:
124
+ # A tap that could not be applied (unknown request, or a lease whose key the gateway
125
+ # would not mint) leaves the request pending; the dashboard says why.
126
+ log.warning("decision for request %s not applied: %s", request_id, e)
127
+ return
128
+ log.info("decision via %s: %s -> %s (%s)", self.driver_id, request_id, req.status, scope)
129
+
130
+
131
+ manager = NotifierManager()
@@ -0,0 +1,79 @@
1
+ """Generic webhook driver: POSTs the request as JSON. Works with ntfy, Pushover relays, n8n,
2
+ Slack incoming webhooks via a small template, or anything that can render links."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import ClassVar
7
+
8
+ import httpx
9
+
10
+ from ..models import AccessRequest, Agent, Nudge
11
+ from .base import Field, Notifier, ProbeResult, nudge_summary, register, request_summary
12
+
13
+
14
+ @register
15
+ class WebhookNotifier(Notifier):
16
+ id = "webhook"
17
+ label = "Webhook (JSON POST)"
18
+ description = "POST {title, body, request, links} to a URL. Humans decide via the signed links."
19
+ fields: ClassVar[list[Field]] = [
20
+ Field("url", "Webhook URL", "url", required=True),
21
+ Field("auth_header", "Authorization header value", "password", help="Optional, sent as-is."),
22
+ Field("format", "Payload format", "select", options=[("json", "Keywright JSON"), ("ntfy", "ntfy.sh")]),
23
+ ]
24
+
25
+ async def probe(self) -> ProbeResult:
26
+ url = self.config.get("url", "")
27
+ if not url.startswith(("http://", "https://")):
28
+ return ProbeResult(ok=False, message="URL must start with http:// or https://")
29
+ return ProbeResult(ok=True, message="Saved. A test POST is sent on the first request.")
30
+
31
+ async def send(self, req: AccessRequest, agent: Agent, links: dict[str, str], buttons: list[str]) -> None:
32
+ from ..approvals import SCOPE_LABELS
33
+
34
+ title, body = request_summary(req, agent)
35
+ headers = {}
36
+ if self.config.get("auth_header"):
37
+ headers["Authorization"] = self.config["auth_header"]
38
+ if self.config.get("format") == "ntfy":
39
+ # ntfy rejects the whole message with a 400 when it carries more than three actions.
40
+ shown = self.capped([s for s in buttons if s in links])
41
+ actions = ", ".join(f"view, {SCOPE_LABELS[s]}, {links[s]}" for s in shown)
42
+ headers.update({"Title": title, "Actions": actions, "Tags": "lock"})
43
+ payload = body.encode()
44
+ async with httpx.AsyncClient(timeout=10) as c:
45
+ (await c.post(self.config["url"], content=payload, headers=headers)).raise_for_status()
46
+ return
47
+ payload = {
48
+ "title": title,
49
+ "body": body,
50
+ "request": {
51
+ "id": req.id, "agent": agent.name, "patterns": req.patterns, "reason": req.reason,
52
+ "detail": req.detail, "created_at": req.created_at.isoformat(),
53
+ },
54
+ "links": links,
55
+ }
56
+ async with httpx.AsyncClient(timeout=10) as c:
57
+ (await c.post(self.config["url"], json=payload, headers=headers)).raise_for_status()
58
+
59
+ async def send_nudge(self, nudge: Nudge, agent: Agent, url: str) -> None:
60
+ title, body = nudge_summary(nudge, agent)
61
+ headers = {}
62
+ if self.config.get("auth_header"):
63
+ headers["Authorization"] = self.config["auth_header"]
64
+ if self.config.get("format") == "ntfy":
65
+ headers.update({"Title": title, "Actions": f"view, Open dashboard, {url}", "Tags": "speech_balloon"})
66
+ async with httpx.AsyncClient(timeout=10) as c:
67
+ (await c.post(self.config["url"], content=body.encode(), headers=headers)).raise_for_status()
68
+ return
69
+ payload = {
70
+ "title": title,
71
+ "body": body,
72
+ "nudge": {
73
+ "id": nudge.id, "agent": agent.name, "message": nudge.message, "context": nudge.context,
74
+ "session": nudge.session_id, "created_at": nudge.created_at.isoformat(),
75
+ },
76
+ "links": {"open": url},
77
+ }
78
+ async with httpx.AsyncClient(timeout=10) as c:
79
+ (await c.post(self.config["url"], json=payload, headers=headers)).raise_for_status()