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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- 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()
|