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,290 @@
1
+ """Runs one command on one host over SSH. Keys live on the Keywright host only.
2
+
3
+ Resource config for kind=ssh:
4
+ {"host": "10.0.0.5", "port": 22, "user": "root", "key_path": "/var/lib/keywright/keys/web1",
5
+ "host_key": "<pinned 'ssh-ed25519 AAAA...' line, learned on first connect>"}
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import ipaddress
12
+ import re
13
+ import shlex
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import asyncssh
19
+
20
+ MAX_OUTPUT = 1024 * 1024
21
+ MAX_PUT = 1024 * 1024
22
+ CONNECT_TIMEOUT = 30
23
+ MAX_TIMEOUT = 600
24
+
25
+
26
+ @dataclass
27
+ class RunResult:
28
+ exit_code: int | None
29
+ stdout: str
30
+ stderr: str
31
+ truncated: bool
32
+ duration_ms: int
33
+ learned_host_key: str | None = None # set when we pinned the host key on this call
34
+
35
+
36
+ class HostKeyMismatch(Exception):
37
+ pass
38
+
39
+
40
+ def _server_key_line(conn) -> str | None:
41
+ """'ssh-ed25519 AAAA...' for the host key the server proved it holds on this connection."""
42
+ key = conn.get_server_host_key()
43
+ return key.export_public_key().decode().strip() if key is not None else None
44
+
45
+
46
+ def _known_hosts_for(pinned: str):
47
+ """asyncssh known_hosts argument that trusts exactly the pinned key (for any hostname)."""
48
+ try:
49
+ return ([asyncssh.import_public_key(pinned)], [], [])
50
+ except asyncssh.KeyImportError as e:
51
+ raise HostKeyMismatch("stored host key is not a valid public key; forget it in the UI and re-pin") from e
52
+
53
+
54
+ async def run(config: dict[str, Any], command: str, *, timeout: int = 300, user: str | None = None,
55
+ stdin: bytes | None = None) -> RunResult:
56
+ timeout = max(1, min(int(timeout or 300), MAX_TIMEOUT))
57
+ host = config["host"]
58
+ port = int(config.get("port") or 22)
59
+ username = user or config.get("user") or "root"
60
+ key_path = config.get("key_path")
61
+ pinned = config.get("host_key")
62
+ started = time.monotonic()
63
+
64
+ options: dict[str, Any] = {
65
+ "host": host,
66
+ "port": port,
67
+ "username": username,
68
+ "client_keys": [key_path] if key_path else [],
69
+ "agent_path": None,
70
+ "config": None,
71
+ "connect_timeout": CONNECT_TIMEOUT,
72
+ }
73
+ if pinned:
74
+ options["known_hosts"] = _known_hosts_for(pinned)
75
+ else:
76
+ options["known_hosts"] = None # first contact: learn and pin below
77
+
78
+ learned = None
79
+ async with asyncssh.connect(**options) as conn:
80
+ if not pinned:
81
+ learned = _server_key_line(conn)
82
+ # Drain both streams concurrently, retaining at most MAX_OUTPUT bytes in total.
83
+ # conn.run() buffers all output before returning, so slicing afterwards is not a memory limit.
84
+ buffers = [bytearray(), bytearray()]
85
+ remaining = MAX_OUTPUT
86
+ truncated = False
87
+ async with conn.create_process(command, encoding=None) as process:
88
+ if stdin:
89
+ process.stdin.write(stdin)
90
+ process.stdin.write_eof()
91
+
92
+ async def drain(reader, target):
93
+ nonlocal remaining, truncated
94
+ while chunk := await reader.read(32768):
95
+ keep = min(len(chunk), remaining)
96
+ target.extend(chunk[:keep])
97
+ remaining -= keep
98
+ truncated |= keep < len(chunk)
99
+
100
+ try:
101
+ await asyncio.wait_for(asyncio.gather(
102
+ drain(process.stdout, buffers[0]), drain(process.stderr, buffers[1]), process.wait_closed(),
103
+ ), timeout=timeout)
104
+ code = process.exit_status
105
+ except TimeoutError:
106
+ process.close()
107
+ code = 124
108
+ buffers[1] = bytearray(f"keywright: command timed out after {timeout}s".encode())
109
+ if len(buffers[0]) + len(buffers[1]) > MAX_OUTPUT:
110
+ del buffers[0][MAX_OUTPUT - len(buffers[1]):]
111
+ truncated = True
112
+ out, err = (bytes(b).decode(errors="replace") for b in buffers)
113
+ return RunResult(
114
+ exit_code=code,
115
+ stdout=out if isinstance(out, str) else out.decode(errors="replace"),
116
+ stderr=err if isinstance(err, str) else err.decode(errors="replace"),
117
+ truncated=truncated,
118
+ duration_ms=int((time.monotonic() - started) * 1000),
119
+ learned_host_key=learned,
120
+ )
121
+
122
+
123
+ def remote_path(path: str) -> str:
124
+ """An absolute path that can also be spelled as permission-path segments, so a rule can name it
125
+ (ssh/<host>/put/etc/komodo/stacks/**). Anything else is refused rather than escaped."""
126
+ from keywright.patterns import validate
127
+
128
+ if not isinstance(path, str) or not path.startswith("/") or path.endswith("/") or "//" in path:
129
+ raise ValueError("path must be absolute, without a trailing slash or empty segments")
130
+ parts = path[1:].split("/")
131
+ if any(seg in (".", "..") or seg.endswith("*") for seg in parts):
132
+ raise ValueError("path must not contain . or .. segments or end a segment with *")
133
+ try:
134
+ validate("ssh/x/put/" + "/".join(parts))
135
+ except ValueError as e:
136
+ raise ValueError(f"path segments may only use letters, digits and _ . : @ + = , - ({e})") from e
137
+ return path
138
+
139
+
140
+ def put_command(path: str, mode: str) -> str:
141
+ """Write stdin to `path` atomically: temp file beside it, mode set, then renamed into place, so a
142
+ dropped connection never leaves a half-written file. Parent directories are created."""
143
+ if not re.fullmatch(r"0?[0-7]{3}", mode):
144
+ raise ValueError("mode must be three octal digits, e.g. 0644")
145
+ script = ('set -e; f=$1; d=$(dirname -- "$f"); mkdir -p -- "$d"; t=$(mktemp -- "$d/.keywright.XXXXXX"); '
146
+ 'cat > "$t"; chmod "$2" "$t"; mv -f -- "$t" "$f"')
147
+ return f"sh -c {shlex.quote(script)} keywright {shlex.quote(path)} {mode}"
148
+
149
+
150
+ def append_command(path: str) -> str:
151
+ """Append stdin to `path`, creating it (and its directory) if needed, and never widening its mode."""
152
+ script = 'set -e; f=$1; mkdir -p -- "$(dirname -- "$f")"; [ -e "$f" ] || (umask 077; : > "$f"); cat >> "$f"'
153
+ return f"sh -c {shlex.quote(script)} keywright {shlex.quote(path)}"
154
+
155
+
156
+ def get_command(path: str) -> str:
157
+ return f"cat -- {shlex.quote(path)}"
158
+
159
+
160
+ def failure_stage(exc: BaseException) -> tuple[str, str]:
161
+ """Where a run broke, coarse enough to hold no secrets: (stage, what the agent should do)."""
162
+ if isinstance(exc, asyncssh.PermissionDenied):
163
+ return "auth", "The host rejected Keywright's key; the admin has to enrol it on the host."
164
+ if isinstance(exc, (asyncssh.HostKeyNotVerifiable, HostKeyMismatch)):
165
+ return "hostkey", "The host key does not match the pinned one; the admin has to re-probe the resource."
166
+ if isinstance(exc, (OSError, TimeoutError, asyncssh.ConnectionLost, asyncssh.DisconnectError)):
167
+ return "unreachable", "No SSH connection to the host; check that it is up and the port is open."
168
+ return "exec", "The connection worked but the command could not be run."
169
+
170
+
171
+ async def probe(config: dict[str, Any]) -> tuple[bool, str, str | None]:
172
+ """Connection test used by the admin UI. Returns (ok, message, learned_host_key)."""
173
+ try:
174
+ r = await run(config, "echo keywright-ok", timeout=15)
175
+ except asyncssh.PermissionDenied:
176
+ return False, "Authentication failed: is the key authorized on the host?", None
177
+ except asyncssh.HostKeyNotVerifiable as e:
178
+ return False, f"Host key mismatch: {e}", None
179
+ except HostKeyMismatch as e:
180
+ return False, str(e), None
181
+ except (OSError, asyncssh.Error, TimeoutError) as e:
182
+ return False, f"{type(e).__name__}: {e}", None
183
+ if r.exit_code == 0 and "keywright-ok" in r.stdout:
184
+ return True, f"Connected as {config.get('user') or 'root'}@{config['host']} in {r.duration_ms} ms", r.learned_host_key
185
+ return False, f"Unexpected reply (exit {r.exit_code}): {r.stderr[:200]}", r.learned_host_key
186
+
187
+
188
+ # ---- key generation & enrolment -----------------------------------------------------------------
189
+ def generate_key(name: str, keys_dir, comment: str | None = None) -> tuple[str, str]:
190
+ """Create an ed25519 keypair under keys_dir/<name>. Returns (private_path, public_line)."""
191
+ from pathlib import Path
192
+
193
+ keys_dir = Path(keys_dir)
194
+ keys_dir.mkdir(parents=True, exist_ok=True)
195
+ priv = keys_dir / name
196
+ if priv.exists():
197
+ raise FileExistsError(f"{priv} already exists")
198
+ key = asyncssh.generate_private_key("ssh-ed25519", comment=comment or f"keywright->{name}")
199
+ import os
200
+
201
+ fd = os.open(priv, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
202
+ with os.fdopen(fd, "wb") as out:
203
+ out.write(key.export_private_key())
204
+ pub_line = key.export_public_key().decode().strip()
205
+ (keys_dir / f"{name}.pub").write_text(pub_line + "\n")
206
+ return str(priv), pub_line
207
+
208
+
209
+ def authorized_keys_line(pub_line: str, restrict_from: str | None) -> str:
210
+ if restrict_from:
211
+ # This setting represents one outbound address, not arbitrary authorized_keys options.
212
+ restrict_from = str(ipaddress.ip_address(restrict_from))
213
+ if "\n" in pub_line or "\r" in pub_line:
214
+ raise ValueError("Public key must be a single line")
215
+ return (f'from="{restrict_from}" ' if restrict_from else "") + pub_line
216
+
217
+
218
+ def enroll_script(pub_line: str, restrict_from: str | None) -> str:
219
+ """POSIX shell that installs the key for the invoking user, idempotently."""
220
+ line = authorized_keys_line(pub_line, restrict_from)
221
+ key_part = pub_line.split()[1]
222
+ return (
223
+ "set -e\n"
224
+ 'D="$HOME/.ssh"; F="$D/authorized_keys"\n'
225
+ 'mkdir -p "$D" && chmod 700 "$D"\n'
226
+ f"if grep -qF {shlex.quote(key_part)} \"$F\" 2>/dev/null; then echo 'keywright: key already present'; "
227
+ f"else printf '%s\\n' {shlex.quote(line)} >> \"$F\" && echo 'keywright: key installed for' \"$(id -un)@$(hostname)\"; fi\n"
228
+ 'chmod 600 "$F"\n'
229
+ )
230
+
231
+
232
+ async def enroll_with_password(config: dict[str, Any], password: str, pub_line: str, restrict: bool = True,
233
+ *, login_user: str | None = None, escalate: str | None = None,
234
+ root_password: str | None = None) -> tuple[bool, str, str | None]:
235
+ """Log in once with a password, install the public key for the resource user, learn the host key.
236
+
237
+ login_user: account to log in as when password SSH is disabled for the target user (e.g. root).
238
+ escalate: how to become the target user from login_user: "sudo" (uses root_password or the
239
+ login password) or "su" (uses root_password). None when login_user is the target.
240
+ Passwords are used for this connection only and never stored. Returns (ok, message, host_key)."""
241
+ import base64
242
+ import shlex
243
+
244
+ host = config["host"]
245
+ port = int(config.get("port") or 22)
246
+ target = config.get("user") or "root"
247
+ login = (login_user or "").strip() or target
248
+
249
+ def safe_output(value: str) -> str:
250
+ for secret in (password, root_password):
251
+ if secret:
252
+ value = value.replace(secret, "[redacted]")
253
+ return value
254
+
255
+ try:
256
+ async with asyncssh.connect(host, port=port, username=login, password=password,
257
+ known_hosts=_known_hosts_for(config["host_key"]) if config.get("host_key") else None,
258
+ client_keys=[], agent_path=None, config=None, connect_timeout=CONNECT_TIMEOUT) as conn:
259
+ learned = _server_key_line(conn)
260
+ src = conn.get_extra_info("sockname")[0] if restrict else None
261
+ script = enroll_script(pub_line, src)
262
+ if login == target:
263
+ res = await asyncio.wait_for(conn.run(script, check=False), timeout=30)
264
+ else:
265
+ b64 = base64.b64encode(script.encode()).decode()
266
+ inner = f"echo {b64} | base64 -d | sh"
267
+ if escalate == "su":
268
+ cmd = f"su - {shlex.quote(target)} -c {shlex.quote(inner)}"
269
+ res = await asyncio.wait_for(
270
+ conn.run(cmd, input=(root_password or "") + "\n", term_type="xterm", check=False), timeout=45)
271
+ elif escalate == "sudo":
272
+ cmd = f"sudo -H -S -p '' -u {shlex.quote(target)} sh -c {shlex.quote(inner)}"
273
+ res = await asyncio.wait_for(
274
+ conn.run(cmd, input=(root_password or password) + "\n", check=False), timeout=45)
275
+ else:
276
+ return False, f"Logged in as {login}, but no escalation method chosen to become {target}.", learned
277
+ out = safe_output(res.stdout or "").replace("\r", "")
278
+ if res.exit_status != 0:
279
+ err = safe_output(res.stderr or "")[:300] or out[-300:]
280
+ return False, f"install script failed (exit {res.exit_status}): {err.strip()}", learned
281
+ note = next((ln.strip() for ln in out.splitlines() if "keywright:" in ln), out.strip()[-200:])
282
+ except asyncssh.PermissionDenied:
283
+ return False, f"Password rejected for {login}@{host} (or password logins are disabled for that user).", None
284
+ except (OSError, asyncssh.Error, TimeoutError, HostKeyMismatch) as e:
285
+ return False, f"{type(e).__name__}: {e}", None
286
+ ok, msg, _ = await probe({**config, "host_key": learned} if learned else config)
287
+ if not ok:
288
+ return False, f"{note}; but key login as {target} failed afterwards: {msg}", learned
289
+ tail = f" Restricted to source {src}." if restrict else ""
290
+ return True, f"{note}; key login verified ({msg}).{tail}", learned
@@ -0,0 +1,260 @@
1
+ """The Access page and the tool watch: what an MCP server offers, what each agent may do with it,
2
+ and what changed upstream since the owner last looked.
3
+
4
+ Rows are built from the live tool list (or a gateway's models). Each row has one rule path, the
5
+ tool's path narrowed by an optional scope such as `account_name=agents` where the tool takes that
6
+ argument, and a state per agent: allow, ask (no rule; the resource default decides) or deny.
7
+ Saving writes only the difference, as ordinary standing rules, and touches no rule it did not
8
+ make the exact shape of: qualified or constrained rules made elsewhere are shown, not edited.
9
+
10
+ The watch keeps a fingerprint per tool (its input schema and annotations) in the resource
11
+ config. A new tool is harmless on its own (no rule covers it, so it asks); a changed one can
12
+ matter: an argument added to an allowed tool is not qualified by any argument key the owner set,
13
+ so it passes on the tool's rule until the owner looks. Drift is recorded and shown, never acted on.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from dataclasses import dataclass, field
21
+ from datetime import UTC, datetime
22
+ from typing import Any
23
+
24
+ from keywright.patterns import matches, qualifier
25
+
26
+ from . import bundles
27
+
28
+ GROUPS = ("read-only", "write", "destructive", "gone")
29
+ STATES = ("allow", "ask", "deny")
30
+
31
+
32
+ # ---- tools -------------------------------------------------------------------------------------
33
+ def group_of(tool: Any) -> str:
34
+ """The server's own annotations first (MCP readOnlyHint / destructiveHint), the verb in the
35
+ name when it says nothing."""
36
+ ann = getattr(tool, "annotations", None)
37
+ if ann is not None:
38
+ if getattr(ann, "read_only_hint", None) is True:
39
+ return "read-only"
40
+ if getattr(ann, "destructive_hint", None) is True:
41
+ return "destructive"
42
+ if getattr(ann, "read_only_hint", None) is False and getattr(ann, "destructive_hint", None) is False:
43
+ return "write"
44
+ bucket = next(iter(bundles.suggest([tool.name])))
45
+ return {"read-only": "read-only", "destructive": "destructive"}.get(bucket, "write")
46
+
47
+
48
+ def _schema(tool: Any) -> dict[str, Any]:
49
+ return dict(getattr(tool, "input_schema", None) or getattr(tool, "inputSchema", None) or {})
50
+
51
+
52
+ def arguments(tool: Any) -> list[str]:
53
+ return sorted((_schema(tool).get("properties") or {}).keys())
54
+
55
+
56
+ def takes(tool: Any, key: str) -> bool:
57
+ """Whether a dotted argument key exists in the tool's input schema."""
58
+ node: Any = _schema(tool)
59
+ for part in key.split("."):
60
+ props = node.get("properties") if isinstance(node, dict) else None
61
+ if not isinstance(props, dict) or part not in props:
62
+ return False
63
+ node = props[part]
64
+ return True
65
+
66
+
67
+ def fingerprint(tool: Any) -> dict[str, Any]:
68
+ ann = getattr(tool, "annotations", None)
69
+ body = json.dumps({"schema": _schema(tool), "annotations": ann.model_dump(exclude_none=True) if ann else None},
70
+ sort_keys=True, default=str)
71
+ return {"hash": hashlib.sha256(body.encode()).hexdigest()[:16], "args": arguments(tool)}
72
+
73
+
74
+ @dataclass
75
+ class Drift:
76
+ added: list[str] = field(default_factory=list)
77
+ removed: list[str] = field(default_factory=list)
78
+ changed: list[dict[str, Any]] = field(default_factory=list) # {tool, new_args, dropped_args}
79
+
80
+ def __bool__(self) -> bool:
81
+ return bool(self.added or self.removed or self.changed)
82
+
83
+ def as_dict(self) -> dict[str, Any]:
84
+ return {"added": self.added, "removed": self.removed, "changed": self.changed}
85
+
86
+
87
+ def diff(seen: dict[str, dict[str, Any]], tools: list[Any]) -> Drift:
88
+ now_fp = {t.name: fingerprint(t) for t in tools}
89
+ d = Drift(added=sorted(set(now_fp) - set(seen)), removed=sorted(set(seen) - set(now_fp)))
90
+ for name in sorted(set(now_fp) & set(seen)):
91
+ old, new = seen[name], now_fp[name]
92
+ if old.get("hash") != new["hash"]:
93
+ d.changed.append({"tool": name,
94
+ "new_args": sorted(set(new["args"]) - set(old.get("args") or [])),
95
+ "dropped_args": sorted(set(old.get("args") or []) - set(new["args"]))})
96
+ return d
97
+
98
+
99
+ def baseline(tools: list[Any]) -> dict[str, Any]:
100
+ return {"tools_seen": {t.name: fingerprint(t) for t in tools},
101
+ "tools_seen_at": datetime.now(UTC).isoformat(timespec="seconds"), "tools_drift": None}
102
+
103
+
104
+ def record(config: dict[str, Any], tools: list[Any]) -> tuple[dict[str, Any], Drift | None]:
105
+ """The resource config after a watch pass: the first pass takes a baseline silently; later ones
106
+ store the drift against it (or clear it) and leave the baseline for the owner to accept."""
107
+ seen = config.get("tools_seen")
108
+ if seen is None:
109
+ return {**config, **baseline(tools)}, None
110
+ d = diff(seen, tools)
111
+ stored = {**d.as_dict(), "at": datetime.now(UTC).isoformat(timespec="seconds")} if d else None
112
+ return {**config, "tools_drift": stored}, d
113
+
114
+
115
+ # ---- rows and rules ----------------------------------------------------------------------------
116
+ @dataclass
117
+ class Row:
118
+ name: str
119
+ group: str
120
+ pattern: str
121
+ description: str = ""
122
+ state: str = "ask"
123
+ everyone: str | None = None # an every-agent rule on the same path, when viewing one agent
124
+ other_rules: int = 0 # rules on this tool of another shape (qualified, constrained)
125
+ covered_by: list[str] = field(default_factory=list) # broader rules that already decide this row
126
+ flag: str | None = None # new | changed | gone
127
+ new_args: list[str] = field(default_factory=list)
128
+
129
+
130
+ def scoped(base: str, scope: tuple[str, str] | None, tool: Any | None) -> str:
131
+ if scope and tool is not None and takes(tool, scope[0]):
132
+ return f"{base}/{qualifier(scope[0], scope[1])}"
133
+ return base
134
+
135
+
136
+ def parse_scope(text: str, arg_keys: list[str]) -> tuple[str, str] | None:
137
+ text = (text or "").strip()
138
+ if not text:
139
+ return None
140
+ if "=" not in text:
141
+ raise ValueError("Scope is key=value, e.g. account_name=agents")
142
+ key, value = (s.strip() for s in text.split("=", 1))
143
+ if key not in arg_keys:
144
+ raise ValueError(f"{key!r} is not one of this server's argument keys ({', '.join(arg_keys) or 'none set'}); "
145
+ "add it on the resource first, or rules on it would never match")
146
+ if not value or "*" in value:
147
+ raise ValueError("Scope needs a literal value")
148
+ return key, value
149
+
150
+
151
+ def state_of(policies: list[Any], pattern: str, agent_id: str | None) -> str:
152
+ exact = [p for p in policies if p.pattern == pattern and not p.constraints and p.agent_id == agent_id]
153
+ if any(p.effect == "deny" for p in exact):
154
+ return "deny"
155
+ return "allow" if exact else "ask"
156
+
157
+
158
+ def touches(policy_pattern: str, tool_path: str) -> bool:
159
+ return policy_pattern == tool_path or policy_pattern.startswith(tool_path + "/")
160
+
161
+
162
+ def mcp_rows(res: Any, tools: list[Any], policies: list[Any], agent_id: str | None,
163
+ scope: tuple[str, str] | None) -> list[Row]:
164
+ seen = res.config.get("tools_seen") or {}
165
+ drift = res.config.get("tools_drift") or {}
166
+ changed = {c["tool"]: c for c in drift.get("changed") or []}
167
+ rows = []
168
+ for t in sorted(tools, key=lambda t: t.name):
169
+ base = f"mcp/{res.name}/tools/{t.name}"
170
+ rows.append(_row(t.name, group_of(t), scoped(base, scope, t), base, policies, agent_id,
171
+ (t.description or "").strip().splitlines()[0][:160] if t.description else "",
172
+ flag="new" if seen and t.name not in seen else "changed" if t.name in changed else None,
173
+ new_args=(changed.get(t.name) or {}).get("new_args") or []))
174
+ # Rules on tools the server no longer offers: one row per rule, on its exact path, so Ask
175
+ # removes it whatever its shape.
176
+ live = {t.name for t in tools}
177
+ prefix = f"mcp/{res.name}/tools/"
178
+ stale = sorted({p.pattern for p in policies if p.agent_id == agent_id and not p.constraints
179
+ and p.pattern.startswith(prefix)
180
+ and p.pattern[len(prefix):].split("/", 1)[0] not in live | {"*", "**"}})
181
+ for pattern in stale:
182
+ rows.append(_row(pattern[len(prefix):], "gone", pattern, pattern, policies, agent_id,
183
+ "No longer offered by the server; this rule can go.", flag="gone"))
184
+ return rows
185
+
186
+
187
+ def llm_rows(res: Any, model_paths: dict[str, str], policies: list[Any], agent_id: str | None) -> list[Row]:
188
+ return [_row(m, "models", path, path, policies, agent_id, "") for m, path in model_paths.items()]
189
+
190
+
191
+ def _row(name, group, pattern, base, policies, agent_id, description, flag=None, new_args=None) -> Row:
192
+ mine = [p for p in policies if p.agent_id == agent_id]
193
+ # Rules that match this row's path without being its own: a wildcard or a shorter prefix. They
194
+ # decide the call whatever this row says (a deny anywhere wins), so the page says so.
195
+ covered = [f"{p.effect} {p.pattern}{'' if p.agent_id else ' (every agent)'}" for p in policies
196
+ if (p.agent_id in (agent_id, None)) and not p.constraints and p.pattern != pattern
197
+ and matches(p.pattern, pattern)]
198
+ return Row(covered_by=covered, name=name, group=group, pattern=pattern, description=description,
199
+ state=state_of(policies, pattern, agent_id),
200
+ everyone=(state_of(policies, pattern, None) if agent_id else None),
201
+ other_rules=sum(1 for p in mine if touches(p.pattern, base) and (p.pattern != pattern or p.constraints)),
202
+ flag=flag, new_args=new_args or [])
203
+
204
+
205
+ @dataclass
206
+ class Plan:
207
+ add: list[tuple[str, str]] = field(default_factory=list) # (pattern, effect)
208
+ delete: list[Any] = field(default_factory=list) # Policy rows
209
+
210
+ def __bool__(self) -> bool:
211
+ return bool(self.add or self.delete)
212
+
213
+
214
+ def plan(rows: list[Row], wanted: dict[str, str], policies: list[Any], agent_id: str | None) -> Plan:
215
+ """The rule changes that take each row from its state to the wanted one. Rows not in `wanted`
216
+ (not on the submitted form) are left alone."""
217
+ out = Plan()
218
+ for row in rows:
219
+ want = wanted.get(row.name)
220
+ if want not in STATES or want == row.state:
221
+ continue
222
+ out.delete += [p for p in policies if p.pattern == row.pattern and not p.constraints
223
+ and p.agent_id == agent_id]
224
+ if want != "ask":
225
+ out.add.append((row.pattern, want))
226
+ return out
227
+
228
+
229
+ # ---- the watch -----------------------------------------------------------------------------------
230
+ async def watch_once(db) -> list[tuple[str, Drift]]:
231
+ """One pass over every MCP server: baseline the new ones, record drift on the rest. A server
232
+ that cannot be reached is skipped (its last drift stands). Returns the servers whose drift is
233
+ new since the last pass, which are also written to the audit log."""
234
+ import logging
235
+
236
+ from sqlalchemy import select
237
+
238
+ from . import audit, mcp_proxy
239
+ from .models import Resource
240
+
241
+ log = logging.getLogger("keywright.tools")
242
+ fresh: list[tuple[str, Drift]] = []
243
+ for res in (await db.execute(select(Resource).where(Resource.kind == "mcp"))).scalars().all():
244
+ try:
245
+ tools = await mcp_proxy.list_tools(res.name, res.config, force=True)
246
+ except Exception as e: # noqa: BLE001
247
+ log.info("tool watch: %s unreachable: %s", res.name, mcp_proxy.describe(e))
248
+ continue
249
+ before = (res.config.get("tools_drift") or {})
250
+ config, d = record(res.config, tools)
251
+ if config == res.config:
252
+ continue
253
+ res.config = config
254
+ await db.commit()
255
+ if d and {k: before.get(k) for k in ("added", "removed", "changed")} != d.as_dict():
256
+ fresh.append((res.name, d))
257
+ log.warning("tool watch: %s changed: %s", res.name, d.as_dict())
258
+ await audit.record(db, tool="tools.drift", decision="changed",
259
+ detail={"resource": res.name, **d.as_dict()})
260
+ return fresh
@@ -0,0 +1,121 @@
1
+ """Usage totals from local proxy audit events, with independent provider identities."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import select
9
+
10
+ from .models import AuditEvent, LlmKey, Resource
11
+
12
+
13
+ def count(value):
14
+ return value if type(value) is int and 0 <= value else None
15
+
16
+
17
+ def token_counts(usage):
18
+ usage = usage if isinstance(usage, dict) else {}
19
+ incoming = count(usage.get("prompt", usage.get("input")))
20
+ outgoing = count(usage.get("completion", usage.get("output")))
21
+ # Anthropic cache counts are additional input; OpenAI cached tokens are a subset.
22
+ if incoming is not None and "prompt" not in usage:
23
+ incoming += (count(usage.get("cache_creation_input")) or 0)
24
+ incoming += (count(usage.get("cache_read_input")) or 0)
25
+ total = count(usage.get("total"))
26
+ if total is None and (incoming is not None or outgoing is not None):
27
+ total = (incoming or 0) + (outgoing or 0)
28
+ return incoming, outgoing, total
29
+
30
+
31
+ @dataclass
32
+ class Totals:
33
+ requests: int = 0
34
+ errors: int = 0
35
+ reported: int = 0
36
+ unreported: int = 0
37
+ partial: int = 0
38
+ input: int = 0
39
+ output: int = 0
40
+ total: int = 0
41
+
42
+ def add(self, event):
43
+ incoming, outgoing, total = token_counts(event.detail.get("usage"))
44
+ self.requests += 1
45
+ self.errors += event.decision == "error"
46
+ self.reported += total is not None
47
+ self.unreported += total is None
48
+ self.partial += total is not None and (
49
+ incoming is None or outgoing is None or event.detail.get("response_complete") is False)
50
+ self.input += incoming or 0
51
+ self.output += outgoing or 0
52
+ self.total += total or 0
53
+
54
+
55
+ async def report(db, start: datetime, end: datetime, *, provider="", model="", agent="", key=""):
56
+ # Only select safe identity fields, never load upstream secrets into the report.
57
+ resources = (await db.execute(select(Resource.id, Resource.name).where(Resource.kind == "llm"))).all()
58
+ resource_names = dict(resources)
59
+ resource_ids = {name: rid for rid, name in resources}
60
+ aliases = {}
61
+ for kid, rid, aid, alias in (await db.execute(select(
62
+ LlmKey.id, LlmKey.resource_id, LlmKey.agent_id, LlmKey.key_alias))).all():
63
+ identity = (rid, aid, alias)
64
+ # Ambiguous historical aliases must stay separate from known key IDs.
65
+ aliases[identity] = kid if identity not in aliases else None
66
+
67
+ options = {name: {} for name in ("provider", "model", "agent", "key")}
68
+ providers, models, keys, days = {}, {}, {}, {}
69
+ summary = Totals()
70
+ recent = []
71
+ stmt = select(AuditEvent).where(
72
+ AuditEvent.tool == "llm.call", AuditEvent.ts >= start, AuditEvent.ts < end,
73
+ AuditEvent.decision.in_(["allow", "error"])
74
+ ).order_by(AuditEvent.ts.desc(), AuditEvent.id.desc()).execution_options(yield_per=500)
75
+ async for event in (await db.stream_scalars(stmt)):
76
+ detail = event.detail
77
+ # Exclude discovery, blocked calls, and key-mint failures before an upstream attempt.
78
+ if not detail.get("model") or not (detail.get("gateway_key_id") or detail.get("key_alias")):
79
+ continue
80
+ gateway = detail.get("gateway") or "Unknown provider"
81
+ pid = detail.get("resource_id") or resource_ids.get(gateway) or "legacy:" + gateway
82
+ provider_name = resource_names.get(pid, gateway)
83
+ model_name = detail["model"]
84
+ aid = event.agent_id or "legacy:" + event.agent_name
85
+ agent_name = event.agent_name or "Unknown agent"
86
+ alias = detail.get("key_alias") or "Unidentified key"
87
+ kid = (detail.get("gateway_key_id") or aliases.get((pid, event.agent_id, alias))
88
+ or "legacy:" + alias)
89
+ # A key selector includes its provider and agent, including for legacy aliases.
90
+ key_identity = json.dumps([pid, aid, kid], separators=(",", ":"))
91
+ labels = {"provider": (pid, provider_name), "model": (model_name, model_name),
92
+ "agent": (aid, agent_name),
93
+ "key": (key_identity, f"{alias} · {provider_name} · {agent_name} · {kid}")}
94
+ for dimension, (value, label) in labels.items():
95
+ options[dimension][value] = label
96
+ if any(selected and labels[dimension][0] != selected for dimension, selected in (
97
+ ("provider", provider), ("model", model), ("agent", agent), ("key", key))):
98
+ continue
99
+ summary.add(event)
100
+ p = providers.setdefault(pid, {"id": pid, "name": provider_name, "totals": Totals(), "models": {}})
101
+ p["totals"].add(event)
102
+ m = p["models"].setdefault(model_name, {"name": model_name, "totals": Totals()})
103
+ m["totals"].add(event)
104
+ models.setdefault(model_name, Totals()).add(event)
105
+ k = keys.setdefault(key_identity, {"id": key_identity, "alias": alias, "key_id": kid,
106
+ "provider": provider_name, "agent": agent_name,
107
+ "totals": Totals(), "models": set()})
108
+ k["totals"].add(event)
109
+ k["models"].add(model_name)
110
+ day = event.ts.date().isoformat()
111
+ days.setdefault(day, Totals()).add(event)
112
+ if len(recent) < 50:
113
+ recent.append({"id": event.id, "ts": event.ts, "provider": provider_name, "model": model_name,
114
+ "agent": agent_name, "alias": alias, "decision": event.decision,
115
+ "total": token_counts(detail.get("usage"))[2]})
116
+ ranked = lambda row: (-row["totals"].total, -row["totals"].requests)
117
+ return {"totals": summary, "providers": sorted(providers.values(), key=ranked),
118
+ "models": sorted(models.items(), key=lambda row: (-row[1].total, -row[1].requests)),
119
+ "keys": sorted(keys.values(), key=ranked), "days": sorted(days.items()), "recent": recent,
120
+ "options": {name: sorted(values.items(), key=lambda pair: pair[1].lower())
121
+ for name, values in options.items()}}
File without changes