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,1025 @@
1
+ """The agent-facing MCP server. Every agent sees access_*; gated tools appear as access allows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import time
9
+ from typing import Any
10
+
11
+ import mcp_types as types
12
+ from keywright import hazards
13
+ from keywright.classify import classify, classify_mcp
14
+ from keywright.patterns import PATTERN_HELP, validate
15
+ from mcp.server.lowlevel import Server
16
+ from sqlalchemy import String, cast, func, select
17
+ from sqlalchemy.exc import IntegrityError
18
+
19
+ from . import (
20
+ __version__,
21
+ approvals,
22
+ audit,
23
+ bundles,
24
+ inventory,
25
+ inventory_mcp,
26
+ knowledge,
27
+ knowledge_mcp,
28
+ mcp_proxy,
29
+ nudges,
30
+ policy,
31
+ result_inspection,
32
+ semantic,
33
+ ssh,
34
+ )
35
+ from .db import session
36
+ from .models import Agent, AuditEvent, Resource, now
37
+
38
+ log = logging.getLogger("keywright.mcp")
39
+ DEFAULT_WAIT = 90
40
+ MAX_WAIT = 600
41
+ MAX_OPERATIONS = 20
42
+
43
+ _agent_cache: dict[str, tuple[float, str]] = {} # token_hash -> (ts, agent_id)
44
+
45
+
46
+ def hash_token(token: str) -> str:
47
+ return hashlib.sha256(token.encode()).hexdigest()
48
+
49
+
50
+ async def resolve_agent(db, headers) -> Agent | None:
51
+ if not headers:
52
+ return None
53
+ auth = headers.get("authorization") or headers.get("Authorization") or ""
54
+ if not auth.lower().startswith("bearer "):
55
+ return None
56
+ token = auth[7:].strip()
57
+ if not token:
58
+ return None
59
+ h = hash_token(token)
60
+ agent = (await db.execute(select(Agent).where(Agent.token_hash == h))).scalar_one_or_none()
61
+ if agent is None or agent.disabled:
62
+ return None
63
+ agent.last_seen_at = now()
64
+ await db.commit()
65
+ return agent
66
+
67
+
68
+ def _headers(ctx) -> dict[str, str] | None:
69
+ req = getattr(ctx, "request", None)
70
+ if req is not None and hasattr(req, "headers"):
71
+ return {k.lower(): v for k, v in req.headers.items()}
72
+ return None
73
+
74
+
75
+ def _session_id(ctx, headers: dict[str, str] | None) -> str | None:
76
+ """Best available session identity.
77
+
78
+ Legacy streamable HTTP carries Mcp-Session-Id. The 2026-07-28 single-exchange mode has no
79
+ session at all, so clients that want per-process scoping can send X-Keywright-Session (the
80
+ pre-rename X-Gatekeeper-Session is still accepted for one release). With
81
+ neither there is no session, and "this session" approvals are not offered for the request.
82
+ """
83
+ if headers:
84
+ for h in ("mcp-session-id", "x-keywright-session", "x-gatekeeper-session"):
85
+ if headers.get(h):
86
+ return headers[h][:128]
87
+ return getattr(ctx, "session_id", None)
88
+
89
+
90
+ # ---- tool definitions ---------------------------------------------------------------------------
91
+ ACCESS_TOOLS = [
92
+ types.Tool(
93
+ name="access_list_resources",
94
+ description="List every resource Keywright brokers (SSH hosts, MCP servers) and your current standing on "
95
+ "each: open, approval-required, locked, plus active grants and standing rules.",
96
+ input_schema={"type": "object", "properties": {}},
97
+ annotations=types.ToolAnnotations(read_only_hint=True),
98
+ ),
99
+ types.Tool(
100
+ name="access_request",
101
+ description="Ask the human for access before you need it, e.g. every read-only tool of a server in one "
102
+ "request: patterns=[\"mcp/ha/tools/ha_get_*\", \"mcp/ha/tools/ha_search\"], or a named "
103
+ "bundle the resource owner defined: \"mcp/ha/bundle/read-only\" (names and contents are in "
104
+ "`bundles` from access_list_resources; the human sees the request grouped the same way). Blocks up to "
105
+ "wait_seconds for a decision and returns approved | denied | pending. Gated tools also request "
106
+ "automatically when called, so this is optional. " + PATTERN_HELP + " MCP calls are qualified by "
107
+ "the argument keys each resource lists in access_list_resources, so a request or rule can name "
108
+ "mcp/ha/tools/call_service/service=light.turn_on. Approval scopes: once (one use per listed scope), session "
109
+ "(this MCP session id, or the X-Keywright-Session header you send; not offered when you have "
110
+ "neither), ttl (15 minutes, 30 minutes, or 1 hour), always (admin UI only). "
111
+ "Automatic approvals default to the exact command/tool arguments for every duration; "
112
+ "the admin can explicitly choose broader patterns. Explicit access_request asks for patterns, "
113
+ "or, with `operations`, for several exact SSH steps at once.",
114
+ input_schema={
115
+ "type": "object",
116
+ "properties": {
117
+ "patterns": {"type": "array", "items": {"type": "string"}, "minItems": 1,
118
+ "description": "Permission paths, e.g. ssh/nvr/exec/journalctl, "
119
+ "mcp/ha/tools/ha_get_*, mcp/ha/tools/call_service/entity_id=light.*"},
120
+ "operations": {
121
+ "type": "array", "minItems": 1, "maxItems": MAX_OPERATIONS,
122
+ "description": "Instead of patterns: the exact SSH operations you are about to run, all in one "
123
+ "prompt. Each is {host, command} for ssh_run, {host, put, content, mode?} for "
124
+ "ssh_put (put = remote path), {host, get} for ssh_get, or {host, command, "
125
+ "to_host, to_path, append?} for ssh_capture; `user` is optional. "
126
+ "Approval binds each one exactly (same command line, same file contents), so "
127
+ "call the tools afterwards with identical arguments. Use this when you know "
128
+ "your next steps: one prompt instead of one per step, without a wide pattern.",
129
+ "items": {"type": "object", "properties": {
130
+ "host": {"type": "string"}, "command": {"type": "string"}, "put": {"type": "string"},
131
+ "content": {"type": "string"}, "mode": {"type": "string"}, "get": {"type": "string"},
132
+ "to_host": {"type": "string"}, "to_path": {"type": "string"}, "append": {"type": "boolean"},
133
+ "to_user": {"type": "string"}, "user": {"type": "string"}}, "required": ["host"]},
134
+ },
135
+ "reason": {"type": "string", "description": "Shown to the human. Be specific."},
136
+ "wait_seconds": {"type": "integer", "default": DEFAULT_WAIT, "minimum": 0, "maximum": MAX_WAIT},
137
+ },
138
+ "required": ["reason"],
139
+ },
140
+ ),
141
+ types.Tool(
142
+ name="request_attention",
143
+ description="Ask the human to come back to this conversation: you have a question only they can "
144
+ "answer, a choice to put to them, or you are done and want them to look. Reaches the "
145
+ "same phone as approval prompts and stays on the Keywright dashboard until they clear "
146
+ "it. It does NOT bring back an answer and does not wait: say what you need here, then "
147
+ "ask it in the conversation and stop. Use it when you would otherwise sit idle while "
148
+ "they are away from the keyboard. Do not use it for progress updates, and do not use "
149
+ "it to chase a pending approval - that request is already on their phone. One ping per "
150
+ "thing you need: repeats of the same message are folded into the one already waiting, "
151
+ "and a second different ping inside a minute is refused.",
152
+ input_schema={
153
+ "type": "object",
154
+ "properties": {
155
+ "message": {"type": "string", "maxLength": nudges.MAX_MESSAGE,
156
+ "description": "What you need from them, in a sentence or two, specific enough "
157
+ "to answer without re-reading the whole conversation."},
158
+ "context": {"type": "string", "maxLength": nudges.MAX_CONTEXT,
159
+ "description": "Where the conversation is, e.g. host:/path or a project name. "
160
+ "The kw CLI fills this in; send it yourself if you know it."},
161
+ },
162
+ "required": ["message"],
163
+ },
164
+ ),
165
+ types.Tool(
166
+ name="access_status",
167
+ description="Check a pending access request by id (optionally wait up to wait_seconds).",
168
+ input_schema={
169
+ "type": "object",
170
+ "properties": {"request_id": {"type": "string"},
171
+ "wait_seconds": {"type": "integer", "default": 0, "minimum": 0, "maximum": MAX_WAIT}},
172
+ "required": ["request_id"],
173
+ },
174
+ annotations=types.ToolAnnotations(read_only_hint=True),
175
+ ),
176
+ ]
177
+
178
+ SSH_TOOL = types.Tool(
179
+ name="ssh_run",
180
+ description="Run one shell command on a brokered host (see access_list_resources for names). Fresh session "
181
+ "per call, no state carries over, output capped at 1 MiB. If the command needs approval, "
182
+ "Keywright asks the human and waits up to wait_seconds; give a clear `reason`.",
183
+ input_schema={
184
+ "type": "object",
185
+ "properties": {
186
+ "host": {"type": "string", "description": "Resource name, e.g. nvr"},
187
+ "command": {"type": "string"},
188
+ "reason": {"type": "string", "description": "Why you need this. Shown to the human if approval is needed."},
189
+ "timeout": {"type": "integer", "default": 300, "minimum": 1, "maximum": ssh.MAX_TIMEOUT},
190
+ "user": {"type": "string", "description": "Override remote user (subject to policy)."},
191
+ "wait_seconds": {"type": "integer", "default": DEFAULT_WAIT, "minimum": 0, "maximum": MAX_WAIT},
192
+ },
193
+ "required": ["host", "command"],
194
+ },
195
+ )
196
+
197
+ SSH_PUT_TOOL = types.Tool(
198
+ name="ssh_put",
199
+ description="Write a file on a brokered host (up to 1 MiB, text). Classified as ssh/<host>/put/<path>, so "
200
+ "a rule can allow a directory: ssh/nvr/put/etc/komodo/stacks/**. The human sees the path, size "
201
+ "and sha256, not the contents, and an exact approval covers exactly these contents at this "
202
+ "path. Written atomically (temp file, mode, rename); parent directories are created.",
203
+ input_schema={
204
+ "type": "object",
205
+ "properties": {
206
+ "host": {"type": "string", "description": "Resource name, e.g. nvr"},
207
+ "path": {"type": "string", "description": "Absolute remote path"},
208
+ "content": {"type": "string"},
209
+ "mode": {"type": "string", "default": "0644", "description": "Octal file mode, e.g. 0600"},
210
+ "reason": {"type": "string", "description": "Why you need this. Shown to the human if approval is needed."},
211
+ "user": {"type": "string", "description": "Override remote user (subject to policy)."},
212
+ "wait_seconds": {"type": "integer", "default": DEFAULT_WAIT, "minimum": 0, "maximum": MAX_WAIT},
213
+ },
214
+ "required": ["host", "path", "content"],
215
+ },
216
+ )
217
+
218
+ SSH_GET_TOOL = types.Tool(
219
+ name="ssh_get",
220
+ description="Read a file from a brokered host (capped at 1 MiB). Classified as ssh/<host>/get/<path>, so a "
221
+ "rule can allow a directory: ssh/nvr/get/etc/komodo/**.",
222
+ input_schema={
223
+ "type": "object",
224
+ "properties": {
225
+ "host": {"type": "string", "description": "Resource name, e.g. nvr"},
226
+ "path": {"type": "string", "description": "Absolute remote path"},
227
+ "reason": {"type": "string", "description": "Why you need this. Shown to the human if approval is needed."},
228
+ "user": {"type": "string", "description": "Override remote user (subject to policy)."},
229
+ "wait_seconds": {"type": "integer", "default": DEFAULT_WAIT, "minimum": 0, "maximum": MAX_WAIT},
230
+ },
231
+ "required": ["host", "path"],
232
+ },
233
+ annotations=types.ToolAnnotations(read_only_hint=True),
234
+ )
235
+
236
+ SSH_CAPTURE_TOOL = types.Tool(
237
+ name="ssh_capture",
238
+ description="Run a command on one host and deliver its stdout to a file on another (or the same) host "
239
+ "without returning it to you: for secrets, tokens and key material that must move between "
240
+ "machines but never through your context. Classified as the command on the source host plus "
241
+ "ssh/<to_host>/put/<to_path>; the human sees both halves in one prompt. You receive exit "
242
+ "codes, byte count and sha256 only; the audit log stores the same. Nothing is written unless "
243
+ "the command exits 0. Shape the output on the source side (print KEY=value) and set "
244
+ "append=true to add it to an env file; otherwise the file is replaced (mode 0600 by default).",
245
+ input_schema={
246
+ "type": "object",
247
+ "properties": {
248
+ "host": {"type": "string", "description": "Source resource name"},
249
+ "command": {"type": "string", "description": "Runs on the source host; its stdout is the payload"},
250
+ "to_host": {"type": "string", "description": "Destination resource name"},
251
+ "to_path": {"type": "string", "description": "Absolute path on the destination host"},
252
+ "append": {"type": "boolean", "default": False},
253
+ "mode": {"type": "string", "default": "0600", "description": "Octal mode when replacing the file"},
254
+ "reason": {"type": "string", "description": "Why you need this. Shown to the human if approval is needed."},
255
+ "user": {"type": "string", "description": "Override the source remote user (subject to policy)."},
256
+ "to_user": {"type": "string", "description": "Override the destination remote user (subject to policy)."},
257
+ "timeout": {"type": "integer", "default": 300, "minimum": 1, "maximum": ssh.MAX_TIMEOUT},
258
+ "wait_seconds": {"type": "integer", "default": DEFAULT_WAIT, "minimum": 0, "maximum": MAX_WAIT},
259
+ },
260
+ "required": ["host", "command", "to_host", "to_path"],
261
+ },
262
+ )
263
+
264
+
265
+ REFUSED_HINT = ("Keywright refuses this outright: it would destroy the host, a home directory or key material. "
266
+ "No approval can allow it. If the user really wants it, they run it by hand. Do not reword it.")
267
+ FLAGGED_HINT = ("This operation is flagged as destructive: standing rules and timed grants do not cover it, "
268
+ "and the human can only allow this exact command line once. Do not reword it to avoid the flag.")
269
+
270
+
271
+ def _handle(v: policy.Verdict, event) -> dict[str, Any]:
272
+ """What an agent can cite for one allowed call: the audit row, what allowed each path, and
273
+ the approval requests behind the grants or standing rules used."""
274
+ out = {"audit_id": event.id, "matched": dict(v.matched), "bound": dict(v.bound),
275
+ "consumed_grants": list(v.consumed_grants), "request_ids": sorted(v.request_ids)}
276
+ if v.hazard:
277
+ out["hazard"] = v.hazard
278
+ return out
279
+
280
+
281
+ def _handle_text(handle: dict[str, Any]) -> str:
282
+ parts = [f"audit #{handle['audit_id']}"]
283
+ # A `**` match on an exact-command grant is that one command, not the host; say so.
284
+ parts += [f"{p} <- {src}" + (f" ({handle['bound'][p]})" if p in handle.get("bound", {}) else "")
285
+ for p, src in handle["matched"].items()]
286
+ if handle["request_ids"]:
287
+ parts.append("requests " + ", ".join(handle["request_ids"]))
288
+ if handle.get("hazard"):
289
+ parts.append(f"flagged {handle['hazard']}")
290
+ return "[keywright] " + " | ".join(parts)
291
+
292
+
293
+ def _result(payload: dict[str, Any], *, text: str | None = None, is_error: bool = False) -> types.CallToolResult:
294
+ return types.CallToolResult(
295
+ content=[types.TextContent(type="text", text=text if text is not None else json.dumps(payload, indent=1))],
296
+ structured_content=payload,
297
+ is_error=is_error,
298
+ )
299
+
300
+
301
+ def _error(kind: str, **extra: Any) -> types.CallToolResult:
302
+ return _result({"error": kind, **extra}, is_error=True)
303
+
304
+
305
+ # ---- handlers -----------------------------------------------------------------------------------
306
+ async def on_list_tools(ctx, params) -> types.ListToolsResult:
307
+ headers = _headers(ctx)
308
+ async with session() as db:
309
+ agent = await resolve_agent(db, headers)
310
+ if agent is None:
311
+ return types.ListToolsResult(tools=[])
312
+ sid = _session_id(ctx, headers)
313
+ tools = list(ACCESS_TOOLS)
314
+ resources = (await db.execute(select(Resource))).scalars().all()
315
+ if any(r.kind == "ssh" for r in resources):
316
+ tools += [SSH_TOOL, SSH_PUT_TOOL, SSH_GET_TOOL, SSH_CAPTURE_TOOL]
317
+ for r in resources:
318
+ if r.kind != "mcp":
319
+ continue
320
+ try:
321
+ upstream = await mcp_proxy.list_tools(r.name, r.config)
322
+ except Exception as e: # noqa: BLE001
323
+ log.warning("upstream %s tools/list failed: %s", r.name, e)
324
+ continue
325
+ for t in upstream:
326
+ v = await policy.evaluate(db, agent, sid, [f"mcp/{r.name}/tools/{t.name}"],
327
+ consume=False, discovery=True)
328
+ if v.status in ("deny", "locked"):
329
+ continue
330
+ tools.append(t.model_copy(update={
331
+ "name": mcp_proxy.export_name(r.name, t.name),
332
+ "description": f"[{r.name}] {t.description or ''}".strip(),
333
+ }))
334
+ return types.ListToolsResult(tools=tools)
335
+
336
+
337
+ async def on_call_tool(ctx, params: types.CallToolRequestParams) -> types.CallToolResult:
338
+ headers = _headers(ctx)
339
+ args = params.arguments or {}
340
+ ua = (headers or {}).get("user-agent") or ""
341
+ audit.client_agent.set(ua[:64] if ua.startswith("keywright/") else None)
342
+ async with session() as db:
343
+ agent = await resolve_agent(db, headers)
344
+ if agent is None:
345
+ return _error("unauthorized", hint="Missing or unknown bearer token.")
346
+ sid = _session_id(ctx, headers)
347
+ name = params.name
348
+ try:
349
+ if name == "access_list_resources":
350
+ out = {"agent": agent.name, "session": sid,
351
+ "resources": await policy.effective_access(db, agent, sid)}
352
+ if sid is None:
353
+ out["session_note"] = approvals.NO_SESSION_NOTE
354
+ return _result(out)
355
+ if name == "access_request":
356
+ return await _access_request(db, agent, sid, args)
357
+ if name == "access_status":
358
+ return await _access_status(db, agent, args)
359
+ if name == "request_attention":
360
+ return await _request_attention(db, agent, sid, args)
361
+ if name == "ssh_run":
362
+ return await _ssh_run(db, agent, sid, args)
363
+ if name == "ssh_put":
364
+ return await _ssh_put(db, agent, sid, args)
365
+ if name == "ssh_get":
366
+ return await _ssh_get(db, agent, sid, args)
367
+ if name == "ssh_capture":
368
+ return await _ssh_capture(db, agent, sid, args)
369
+ split = mcp_proxy.split_name(name)
370
+ if split:
371
+ return await _proxy_call(db, agent, sid, split[0], split[1], args)
372
+ except Exception as e:
373
+ log.exception("tool %s failed", name)
374
+ await audit.record(db, tool=name, decision="error", agent=agent, session_id=sid,
375
+ detail={"error": f"{type(e).__name__}: {e}"})
376
+ return _error("internal", message="Tool failed; see the admin audit log.")
377
+ return _error("unknown_tool", name=name)
378
+
379
+
380
+ async def _access_request(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
381
+ if args.get("operations") is not None:
382
+ if args.get("patterns"):
383
+ return _error("invalid_arguments", hint="Give either patterns or operations, not both.")
384
+ return await _access_request_operations(db, agent, sid, args)
385
+ try:
386
+ raw_patterns = args.get("patterns")
387
+ if not isinstance(raw_patterns, list) or not 1 <= len(raw_patterns) <= 64:
388
+ raise ValueError("Provide between 1 and 64 patterns")
389
+ pats = [validate(p) for p in raw_patterns]
390
+ if any(len(p) > 512 or len(p.split("/")) < 3 or "*" in "/".join(p.split("/")[:2]) for p in pats):
391
+ raise ValueError("Each pattern must identify one concrete resource")
392
+ except (ValueError, TypeError, AttributeError) as e:
393
+ return _error("invalid_pattern", message=str(e), hint=PATTERN_HELP)
394
+ pats, groups, err = await _expand_bundles(db, pats)
395
+ if err:
396
+ return err
397
+ reason = (args.get("reason") or "").strip()
398
+ wait_s = min(int(args.get("wait_seconds", DEFAULT_WAIT) or 0), MAX_WAIT)
399
+ v = await policy.evaluate(db, agent, sid, pats, consume=False)
400
+ if v.status == "allow":
401
+ return _result({"status": "approved", "note": "already allowed", "matched": v.matched})
402
+ if v.status == "deny":
403
+ return _error("denied", patterns=v.denied, hint="A standing deny rule covers this.")
404
+ if v.status == "locked":
405
+ return _error("locked", patterns=v.locked, hint="Cannot be granted from a prompt; needs a rule in the UI.")
406
+ if v.status == "unknown":
407
+ return _error("unknown_resource", patterns=v.unknown, hint="Call access_list_resources.")
408
+ missing = set(v.missing)
409
+ shown = {name: [p for p in ps if p in missing] for name, ps in groups.items()}
410
+ detail = {"bundles": {n: ps for n, ps in shown.items() if ps}} if any(shown.values()) else {}
411
+ req, _ = await _request_or_reuse(db, agent, sid, v.missing, reason, detail)
412
+ if req is None:
413
+ return _error("denied", retry_after=600, hint="Recently denied; ask the user before retrying.")
414
+ if wait_s:
415
+ await approvals.wait(req.id, wait_s)
416
+ await db.refresh(req)
417
+ return await _status_payload(db, req)
418
+
419
+
420
+ ATTENTION_NEXT = ("The human has been pinged. Ask your question in the conversation now and stop; "
421
+ "their answer comes back there, not here. Do not call this again for the same thing.")
422
+
423
+
424
+ async def _request_attention(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
425
+ """A ping, not a request: it authorizes nothing, so it is not evaluated against policy. The
426
+ limit that matters is the human's attention, and nudges.create owns that."""
427
+ try:
428
+ ping = await nudges.create(db, agent, sid, str(args.get("message") or ""),
429
+ str(args.get("context") or ""))
430
+ except ValueError as e:
431
+ return _error("invalid_arguments", message=str(e))
432
+ if ping.status == "throttled":
433
+ await audit.record(db, tool="attention", decision="throttled", agent=agent, session_id=sid,
434
+ detail={"retry_after": ping.retry_after})
435
+ return _error("rate_limited", retry_after=ping.retry_after,
436
+ hint="You pinged them moments ago. Ask in the conversation and wait instead.")
437
+ if ping.status == "backlog":
438
+ await audit.record(db, tool="attention", decision="throttled", agent=agent, session_id=sid,
439
+ detail={"open": nudges.MAX_OPEN})
440
+ return _error("rate_limited", open_nudges=nudges.MAX_OPEN,
441
+ hint=f"You already have {nudges.MAX_OPEN} unanswered pings waiting for them. "
442
+ "Wait for those before asking for more of their attention.")
443
+ return _result({"status": ping.status, "nudge_id": ping.nudge.id,
444
+ "notified": await _notified(db, ping.nudge.id, "nudge_id"),
445
+ "note": ATTENTION_NEXT if ping.status == "sent"
446
+ else "That exact message is already waiting on their phone; nothing new was sent. "
447
+ + ATTENTION_NEXT})
448
+
449
+
450
+ async def _access_request_operations(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
451
+ """Several exact operations in one prompt. Each gets its own patterns and its own exact grant;
452
+ the human sees every command line, and later calls must repeat them verbatim to match."""
453
+ ops = args.get("operations")
454
+ if not isinstance(ops, list) or not 1 <= len(ops) <= MAX_OPERATIONS:
455
+ return _error("invalid_arguments", hint=f"operations must be a list of 1 to {MAX_OPERATIONS} items")
456
+ pending: list[dict[str, Any]] = []
457
+ allowed: dict[str, str] = {}
458
+ for i, op in enumerate(ops):
459
+ if not isinstance(op, dict) or not isinstance(op.get("host"), str):
460
+ return _error("invalid_arguments", index=i, hint="each operation is an object with a host")
461
+ res, user = await _ssh_target(db, op["host"], op.get("user"))
462
+ if res is None:
463
+ return _error("unknown_resource", index=i, host=op["host"], hint="Call access_list_resources for valid host names.")
464
+ try:
465
+ if isinstance(op.get("command"), str) and op["command"].strip() and isinstance(op.get("to_host"), str):
466
+ dst, to_user = await _ssh_target(db, op["to_host"], op.get("to_user"))
467
+ if dst is None:
468
+ return _error("unknown_resource", index=i, host=op["to_host"], hint="Call access_list_resources.")
469
+ if not isinstance(op.get("to_path"), str):
470
+ raise ValueError("a capture needs to_host and to_path")
471
+ required, detail = _capture_operation(res, op["command"].strip(), user, dst, op["to_path"], to_user,
472
+ bool(op.get("append")))
473
+ elif isinstance(op.get("command"), str) and op["command"].strip():
474
+ required, detail = _run_operation(res, op["command"].strip(), user)
475
+ elif isinstance(op.get("put"), str):
476
+ if not isinstance(op.get("content"), str):
477
+ raise ValueError("a put needs content")
478
+ required, detail, _ = _put_operation(res, op["put"], op["content"], user)
479
+ elif isinstance(op.get("get"), str):
480
+ required, detail = _get_operation(res, op["get"], user)
481
+ else:
482
+ raise ValueError("each operation needs a command, a put with content, or a get")
483
+ except ValueError as e:
484
+ return _error("invalid_arguments", index=i, hint=str(e))
485
+ if hazards.worst(detail.get("hazards")) == "critical":
486
+ await audit.record(db, tool="access_request", decision="refused", agent=agent, session_id=sid,
487
+ patterns=required, detail=detail)
488
+ return _error("refused", index=i, hazards=detail["hazards"], hint=REFUSED_HINT)
489
+ v = await policy.evaluate(db, agent, sid, required, consume=False, operation=detail)
490
+ if v.status == "deny":
491
+ return _error("denied", index=i, patterns=v.denied, hint="A standing deny rule covers this.")
492
+ if v.status == "locked":
493
+ return _error("locked", index=i, patterns=v.locked, hint="Cannot be granted from a prompt; needs a rule in the UI.")
494
+ if v.status == "allow":
495
+ allowed.update(v.matched)
496
+ continue
497
+ pending.append({"patterns": v.missing, "operation": detail})
498
+ if not pending:
499
+ return _result({"status": "approved", "note": "already allowed", "matched": allowed})
500
+ patterns: list[str] = []
501
+ for item in pending:
502
+ patterns += [p for p in item["patterns"] if p not in patterns]
503
+ reason = (args.get("reason") or "").strip()
504
+ wait_s = min(int(args.get("wait_seconds", DEFAULT_WAIT) or 0), MAX_WAIT)
505
+ req, _ = await _request_or_reuse(db, agent, sid, patterns, reason, {"operations": pending})
506
+ if req is None:
507
+ return _error("denied", retry_after=600, hint="Recently denied; ask the user before retrying.")
508
+ if wait_s:
509
+ await approvals.wait(req.id, wait_s)
510
+ await db.refresh(req)
511
+ return await _status_payload(db, req)
512
+
513
+
514
+ async def _expand_bundles(db, pats: list[str]) -> tuple[list[str], dict[str, list[str]], types.CallToolResult | None]:
515
+ """Replace mcp/<server>/bundle/<name> entries with the bundle's patterns. Returns (patterns,
516
+ {bundle name: its patterns}, error)."""
517
+ out: list[str] = []
518
+ groups: dict[str, list[str]] = {}
519
+ for p in pats:
520
+ parts = p.split("/")
521
+ if not (parts[0] == "mcp" and len(parts) == 4 and parts[2] == "bundle"):
522
+ if p not in out:
523
+ out.append(p)
524
+ continue
525
+ server, name = parts[1], parts[3]
526
+ res = (await db.execute(select(Resource).where(Resource.kind == "mcp", Resource.name == server))).scalar_one_or_none()
527
+ expanded = bundles.expand(server, (res.config or {}).get("bundles") if res else None, name)
528
+ if expanded is None:
529
+ return [], {}, _error("unknown_bundle", pattern=p,
530
+ available=sorted(((res.config or {}).get("bundles") or {}).keys()) if res else [],
531
+ hint="Bundles are defined per resource; see `bundles` in access_list_resources.")
532
+ groups[f"{server}/{name}"] = expanded
533
+ out.extend(x for x in expanded if x not in out)
534
+ return out, groups, None
535
+
536
+
537
+ async def _access_status(db, agent: Agent, args: dict[str, Any]) -> types.CallToolResult:
538
+ req = await db.get(approvals.AccessRequest, args.get("request_id", ""))
539
+ if req is None or req.agent_id != agent.id:
540
+ return _error("not_found")
541
+ wait_s = min(int(args.get("wait_seconds", 0) or 0), MAX_WAIT)
542
+ if wait_s and req.status == "pending":
543
+ await approvals.wait(req.id, wait_s)
544
+ await db.refresh(req)
545
+ return await _status_payload(db, req)
546
+
547
+
548
+ async def _status_payload(db, req) -> types.CallToolResult:
549
+ payload = {"request_id": req.id, "status": req.status, "patterns": req.patterns,
550
+ "scope": req.decision_scope, "decision_options": req.decision_options,
551
+ "deny_reason": req.deny_reason or None}
552
+ if "operations" in (req.detail or {}):
553
+ payload["operations"] = len(req.detail["operations"])
554
+ else:
555
+ # Enough to name the operation the approval was for, never its payload or arguments.
556
+ op = {k: v for k, v in (req.detail or {}).items()
557
+ if k in ("host", "command", "user", "path", "to_host", "to_path", "server", "tool")}
558
+ if op:
559
+ payload["operation"] = op
560
+ if req.status == "pending":
561
+ payload["notified"] = await _notified(db, req.id)
562
+ payload["hint"] = NOTIFIED_HINTS[payload["notified"]]
563
+ return _result(payload, is_error=req.status in ("denied", "expired"))
564
+
565
+
566
+ NOTIFIED_HINTS = {
567
+ "sent": "The human has the notification and has not answered yet. Poll access_status(request_id, "
568
+ "wait_seconds) or tell the user what you asked for.",
569
+ "failed": "The notification could not be delivered. Tell the user to decide it in the admin UI.",
570
+ "none": "No notifier is configured. Tell the user to decide it in the admin UI.",
571
+ }
572
+
573
+
574
+ async def _notified(db, ref_id: str, key: str = "request_id") -> str:
575
+ """Whether the phone got this request or nudge: a wait that is long because the human is busy
576
+ looks, from the agent's side, exactly like a notification that never went out."""
577
+ from .notify.manager import manager
578
+
579
+ if not manager.active:
580
+ return "none"
581
+ events = (await db.execute(select(AuditEvent).where(
582
+ AuditEvent.tool == "notify", AuditEvent.decision == "error",
583
+ cast(AuditEvent.detail, String).contains(ref_id, autoescape=True)))).scalars().all()
584
+ return "failed" if any((e.detail or {}).get(key) == ref_id for e in events) else "sent"
585
+
586
+
587
+ async def _request_or_reuse(db, agent, sid, patterns, reason, detail, required=None):
588
+ """Returns (request, reused). None when recently denied. A new single-operation request
589
+ carries Jev's verdict (semantic.py); in trust mode a clean verdict approves it once, exactly,
590
+ before anyone is notified. `required` is the full pattern list the verdict's guards look at;
591
+ `patterns` is what is still missing."""
592
+ if await approvals.recently_denied(db, agent, patterns):
593
+ return None, False
594
+ existing = await approvals.find_pending(db, agent, patterns, session_id=sid, detail=detail)
595
+ if existing:
596
+ return existing, True
597
+ count = await db.scalar(select(func.count()).select_from(approvals.AccessRequest).where(
598
+ approvals.AccessRequest.agent_id == agent.id, approvals.AccessRequest.status == "pending",
599
+ approvals.AccessRequest.expires_at > now()))
600
+ if count >= 50:
601
+ raise ValueError("Too many pending requests; wait for existing decisions")
602
+ verdict = await semantic.assess(db, required if required is not None else patterns, reason, detail)
603
+ auto = semantic.auto_approves(verdict)
604
+ req = await approvals.create(db, agent, sid, patterns, reason, detail, semantic=verdict, notify=not auto)
605
+ await semantic.record(db, req, agent, sid, verdict)
606
+ if auto:
607
+ req = await approvals.decide(db, req.id, "once", via="jev")
608
+ return req, False
609
+
610
+
611
+ async def _gate(db, agent: Agent, sid: str | None, required: list[str], reason: str, detail: dict, wait_s: int,
612
+ tool: str) -> tuple[policy.Verdict | None, types.CallToolResult | None]:
613
+ """Shared gate for ssh_run and proxied tools. Returns (verdict, None) when allowed."""
614
+ level = hazards.worst(detail.get("hazards"))
615
+ if level == "critical":
616
+ await audit.record(db, tool=tool, decision="refused", agent=agent, session_id=sid, patterns=required, detail=detail)
617
+ return None, _error("refused", hazards=detail["hazards"], hint=REFUSED_HINT)
618
+ v = await policy.evaluate(db, agent, sid, required, operation=detail)
619
+ if v.allowed:
620
+ return v, None
621
+ if v.status == "deny":
622
+ await audit.record(db, tool=tool, decision="deny", agent=agent, session_id=sid, patterns=required, detail=detail)
623
+ return None, _error("denied", patterns=v.denied, hint="A standing deny rule covers this.")
624
+ if v.status == "locked":
625
+ await audit.record(db, tool=tool, decision="locked", agent=agent, session_id=sid, patterns=required, detail=detail)
626
+ return None, _error("locked", patterns=v.locked,
627
+ hint="This resource can only be opened by a rule in the admin UI. Ask the user.")
628
+ if v.status == "unknown":
629
+ return None, _error("unknown_resource", patterns=v.unknown, hint="Call access_list_resources.")
630
+ req, _ = await _request_or_reuse(db, agent, sid, v.missing, reason, detail, required=required)
631
+ if req is None:
632
+ await audit.record(db, tool=tool, decision="deny", agent=agent, session_id=sid, patterns=required,
633
+ detail={**detail, "note": "recently denied"})
634
+ return None, _error("denied", retry_after=600, hint="Recently denied by the human; do not retry blindly.")
635
+ if wait_s:
636
+ await approvals.wait(req.id, wait_s)
637
+ await db.refresh(req)
638
+ if req.status == "approved":
639
+ v = await policy.evaluate(db, agent, sid, required, operation=detail)
640
+ if v.allowed:
641
+ return v, None
642
+ return None, _error(v.status, hint="Access changed while awaiting approval; request access again.")
643
+ await audit.record(db, tool=tool, decision="pending" if req.status == "pending" else req.status, agent=agent,
644
+ session_id=sid, patterns=required, detail={**detail, "request_id": req.id})
645
+ if req.status == "pending":
646
+ notified = await _notified(db, req.id)
647
+ extra = {"hazards": detail["hazards"], "hazard": level} if level else {}
648
+ return None, _error("approval_required", request_id=req.id, patterns=v.missing, notified=notified,
649
+ hint=NOTIFIED_HINTS[notified] + (" " + FLAGGED_HINT if hazards.blocks(level) else ""), **extra)
650
+ return None, _error("denied" if req.status == "denied" else req.status, request_id=req.id,
651
+ reason=req.deny_reason or None)
652
+
653
+
654
+ async def _ssh_target(db, host: str, user_arg: str | None) -> tuple[Resource | None, str]:
655
+ res = (await db.execute(select(Resource).where(Resource.kind == "ssh", Resource.name == host))).scalar_one_or_none()
656
+ if res is None:
657
+ return None, ""
658
+ return res, (user_arg or "").strip() or res.config.get("user") or "root"
659
+
660
+
661
+ def _user_patterns(res: Resource, user: str) -> list[str]:
662
+ return [f"ssh/{res.name}/user/{user}"] if user != (res.config.get("user") or "root") else []
663
+
664
+
665
+ def _excerpt(text: str, limit: int = 2000, head: int = 600) -> str:
666
+ """Head and tail of a long stderr. Boot noise comes first; what happened is in the last lines."""
667
+ if len(text) <= limit:
668
+ return text
669
+ tail = limit - head
670
+ return f"{text[:head]}\n… [{len(text) - limit} characters omitted] …\n{text[-tail:]}"
671
+
672
+
673
+ def _flagged(detail: dict[str, Any]) -> dict[str, Any]:
674
+ """The operation with its hazard flags. They ride along in requests, audit rows and results;
675
+ `access.operations` strips them before anything is bound to a grant."""
676
+ found = hazards.scan(detail)
677
+ return {**detail, "hazards": hazards.as_dicts(found)} if found else detail
678
+
679
+
680
+ def _run_operation(res: Resource, command: str, user: str) -> tuple[list[str], dict[str, Any]]:
681
+ required = [*classify(res.name, command), *_user_patterns(res, user)]
682
+ return required, _flagged({"host": res.name, "command": command, "user": user, "resource_id": res.id})
683
+
684
+
685
+ def _put_operation(res: Resource, path: str, content: str, user: str) -> tuple[list[str], dict[str, Any], bytes]:
686
+ """The prompt and the grant see the path and a digest of the contents, never the contents."""
687
+ body = content.encode()
688
+ if len(body) > ssh.MAX_PUT:
689
+ raise ValueError(f"content exceeds {ssh.MAX_PUT} bytes")
690
+ path = ssh.remote_path(path)
691
+ required = [f"ssh/{res.name}/put{path}", *_user_patterns(res, user)]
692
+ return required, _flagged({"host": res.name, "path": path, "sha256": hashlib.sha256(body).hexdigest(),
693
+ "bytes": len(body), "user": user, "resource_id": res.id}), body
694
+
695
+
696
+ def _get_operation(res: Resource, path: str, user: str) -> tuple[list[str], dict[str, Any]]:
697
+ path = ssh.remote_path(path)
698
+ return [f"ssh/{res.name}/get{path}", *_user_patterns(res, user)], \
699
+ _flagged({"host": res.name, "path": path, "user": user, "resource_id": res.id})
700
+
701
+
702
+ def _capture_operation(src: Resource, command: str, user: str, dst: Resource, to_path: str, to_user: str,
703
+ append: bool) -> tuple[list[str], dict[str, Any]]:
704
+ """Both halves in one operation: the grant names the command and where its output goes, so an
705
+ approval to capture never lets the same command return its output to the agent."""
706
+ to_path = ssh.remote_path(to_path)
707
+ required, detail = _run_operation(src, command, user)
708
+ required = [*required, f"ssh/{dst.name}/put{to_path}", *_user_patterns(dst, to_user)]
709
+ detail.pop("hazards", None)
710
+ return required, _flagged({**detail, "to_host": dst.name, "to_path": to_path, "to_user": to_user,
711
+ "to_resource_id": dst.id, "append": bool(append)})
712
+
713
+
714
+ async def _ssh_execute(db, agent: Agent, sid: str | None, tool: str, res: Resource, required: list[str],
715
+ detail: dict[str, Any], v: policy.Verdict, command: str, user: str, *,
716
+ timeout: int = 300, stdin: bytes | None = None):
717
+ """Run an allowed operation and audit it. Returns (RunResult, handle) or (None, error result)."""
718
+ await db.refresh(res)
719
+ started = time.monotonic()
720
+ try:
721
+ r = await ssh.run(res.config, command, timeout=timeout, user=user, **({"stdin": stdin} if stdin else {}))
722
+ except Exception as e: # noqa: BLE001
723
+ stage, hint = ssh.failure_stage(e)
724
+ await audit.record(db, tool=tool, decision="error", agent=agent, session_id=sid, patterns=required,
725
+ detail={**detail, "error": f"{type(e).__name__}: {e}", "stage": stage,
726
+ "matched": v.matched, "request_ids": v.request_ids},
727
+ duration_ms=int((time.monotonic() - started) * 1000))
728
+ return None, _error("ssh_failed", stage=stage, hint=hint,
729
+ message="SSH connection or command failed; see the admin audit log.")
730
+ if r.learned_host_key:
731
+ res.config = {**res.config, "host_key": r.learned_host_key}
732
+ await db.commit()
733
+ ev = await audit.record(db, tool=tool, decision="allow", agent=agent, session_id=sid, patterns=required,
734
+ detail={**detail, "matched": v.matched, "consumed": v.consumed_grants,
735
+ "request_ids": v.request_ids,
736
+ "stdout_bytes": len(r.stdout), "stderr_bytes": len(r.stderr)},
737
+ exit_code=r.exit_code, duration_ms=r.duration_ms)
738
+ return r, _handle(v, ev)
739
+
740
+
741
+ async def _ssh_run(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
742
+ host = (args.get("host") or "").strip()
743
+ command = (args.get("command") or "").strip()
744
+ if not host or not command:
745
+ return _error("invalid_arguments", hint="host and command are required")
746
+ res, user = await _ssh_target(db, host, args.get("user"))
747
+ if res is None:
748
+ return _error("unknown_resource", host=host, hint="Call access_list_resources for valid host names.")
749
+ required, detail = _run_operation(res, command, user)
750
+ wait_s = min(int(args.get("wait_seconds", DEFAULT_WAIT) or 0), MAX_WAIT)
751
+ v, err = await _gate(db, agent, sid, required, (args.get("reason") or "").strip(), detail, wait_s, "ssh_run")
752
+ if err:
753
+ return err
754
+ r, out = await _ssh_execute(db, agent, sid, "ssh_run", res, required, detail, v, command, user,
755
+ timeout=int(args.get("timeout") or 300))
756
+ if r is None:
757
+ return out
758
+ handle = out
759
+ text = f"exit_code: {r.exit_code}\n"
760
+ if r.stdout:
761
+ text += f"--- stdout ---\n{r.stdout}\n"
762
+ if r.stderr:
763
+ text += f"--- stderr ---\n{r.stderr}\n"
764
+ if r.truncated:
765
+ text += "[output truncated at 1 MiB]\n"
766
+ text += _handle_text(handle) + "\n"
767
+ payload = {"exit_code": r.exit_code, "stdout": r.stdout, "stderr": r.stderr, "truncated": r.truncated,
768
+ "duration_ms": r.duration_ms, "keywright": handle}
769
+ return _result(payload, text=text, is_error=False)
770
+
771
+
772
+ async def _ssh_put(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
773
+ host = (args.get("host") or "").strip()
774
+ content = args.get("content")
775
+ if not host or not isinstance(args.get("path"), str) or not isinstance(content, str):
776
+ return _error("invalid_arguments", hint="host, path and content (a string) are required")
777
+ res, user = await _ssh_target(db, host, args.get("user"))
778
+ if res is None:
779
+ return _error("unknown_resource", host=host, hint="Call access_list_resources for valid host names.")
780
+ try:
781
+ required, detail, body = _put_operation(res, args["path"], content, user)
782
+ command = ssh.put_command(detail["path"], str(args.get("mode") or "0644"))
783
+ except ValueError as e:
784
+ return _error("invalid_arguments", hint=str(e))
785
+ wait_s = min(int(args.get("wait_seconds", DEFAULT_WAIT) or 0), MAX_WAIT)
786
+ v, err = await _gate(db, agent, sid, required, (args.get("reason") or "").strip(), detail, wait_s, "ssh_put")
787
+ if err:
788
+ return err
789
+ r, out = await _ssh_execute(db, agent, sid, "ssh_put", res, required, detail, v, command, user,
790
+ timeout=120, stdin=body)
791
+ if r is None:
792
+ return out
793
+ if r.exit_code != 0:
794
+ return _error("put_failed", exit_code=r.exit_code, stderr=_excerpt(r.stderr), keywright=out,
795
+ hint="The host refused the write; the path, its directory or the permissions are the usual causes.")
796
+ payload = {"written": detail["path"], "bytes": detail["bytes"], "sha256": detail["sha256"], "keywright": out}
797
+ return _result(payload, text=f"wrote {detail['bytes']} bytes to {detail['path']} (sha256 {detail['sha256'][:12]})\n"
798
+ + _handle_text(out) + "\n")
799
+
800
+
801
+ async def _ssh_get(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
802
+ host = (args.get("host") or "").strip()
803
+ if not host or not isinstance(args.get("path"), str):
804
+ return _error("invalid_arguments", hint="host and path are required")
805
+ res, user = await _ssh_target(db, host, args.get("user"))
806
+ if res is None:
807
+ return _error("unknown_resource", host=host, hint="Call access_list_resources for valid host names.")
808
+ try:
809
+ required, detail = _get_operation(res, args["path"], user)
810
+ except ValueError as e:
811
+ return _error("invalid_arguments", hint=str(e))
812
+ wait_s = min(int(args.get("wait_seconds", DEFAULT_WAIT) or 0), MAX_WAIT)
813
+ v, err = await _gate(db, agent, sid, required, (args.get("reason") or "").strip(), detail, wait_s, "ssh_get")
814
+ if err:
815
+ return err
816
+ r, out = await _ssh_execute(db, agent, sid, "ssh_get", res, required, detail, v,
817
+ ssh.get_command(detail["path"]), user, timeout=120)
818
+ if r is None:
819
+ return out
820
+ if r.exit_code != 0:
821
+ return _error("get_failed", exit_code=r.exit_code, stderr=_excerpt(r.stderr), keywright=out,
822
+ hint="The host could not read the path; check that it exists and is readable.")
823
+ payload = {"path": detail["path"], "content": r.stdout, "truncated": r.truncated, "keywright": out}
824
+ return _result(payload, text=r.stdout + ("\n[content truncated at 1 MiB]" if r.truncated else "")
825
+ + "\n" + _handle_text(out) + "\n")
826
+
827
+
828
+ async def _ssh_capture(db, agent: Agent, sid: str | None, args: dict[str, Any]) -> types.CallToolResult:
829
+ host, to_host = (args.get("host") or "").strip(), (args.get("to_host") or "").strip()
830
+ command = (args.get("command") or "").strip()
831
+ if not host or not command or not to_host or not isinstance(args.get("to_path"), str):
832
+ return _error("invalid_arguments", hint="host, command, to_host and to_path are required")
833
+ src, user = await _ssh_target(db, host, args.get("user"))
834
+ dst, to_user = await _ssh_target(db, to_host, args.get("to_user"))
835
+ if src is None or dst is None:
836
+ return _error("unknown_resource", host=host if src is None else to_host,
837
+ hint="Call access_list_resources for valid host names.")
838
+ append = bool(args.get("append"))
839
+ try:
840
+ required, detail = _capture_operation(src, command, user, dst, args["to_path"], to_user, append)
841
+ write = ssh.append_command(detail["to_path"]) if append else ssh.put_command(detail["to_path"],
842
+ str(args.get("mode") or "0600"))
843
+ except ValueError as e:
844
+ return _error("invalid_arguments", hint=str(e))
845
+ wait_s = min(int(args.get("wait_seconds", DEFAULT_WAIT) or 0), MAX_WAIT)
846
+ v, err = await _gate(db, agent, sid, required, (args.get("reason") or "").strip(), detail, wait_s, "ssh_capture")
847
+ if err:
848
+ return err
849
+ # The payload exists only inside this call: it is never in a result, an audit row or a log line.
850
+ started = time.monotonic()
851
+ try:
852
+ r = await ssh.run(src.config, command, timeout=int(args.get("timeout") or 300), user=user)
853
+ if r.exit_code != 0 or r.truncated:
854
+ why = "output exceeds 1 MiB" if r.truncated else f"command exited {r.exit_code}"
855
+ ev = await audit.record(db, tool="ssh_capture", decision="allow", agent=agent, session_id=sid,
856
+ patterns=required, detail={**detail, "matched": v.matched, "consumed": v.consumed_grants,
857
+ "request_ids": v.request_ids, "written": False,
858
+ "stdout_bytes": len(r.stdout), "stderr_bytes": len(r.stderr)},
859
+ exit_code=r.exit_code, duration_ms=int((time.monotonic() - started) * 1000))
860
+ return _error("capture_failed", exit_code=r.exit_code, stderr=_excerpt(r.stderr), keywright=_handle(v, ev),
861
+ hint=f"Nothing was written: {why}.")
862
+ body = r.stdout.encode()
863
+ await db.refresh(dst)
864
+ w = await ssh.run(dst.config, write, timeout=120, user=to_user, stdin=body)
865
+ except Exception as e: # noqa: BLE001
866
+ stage, hint = ssh.failure_stage(e)
867
+ await audit.record(db, tool="ssh_capture", decision="error", agent=agent, session_id=sid, patterns=required,
868
+ detail={**detail, "error": f"{type(e).__name__}: {e}", "stage": stage,
869
+ "matched": v.matched, "request_ids": v.request_ids},
870
+ duration_ms=int((time.monotonic() - started) * 1000))
871
+ return _error("ssh_failed", stage=stage, hint=hint,
872
+ message="SSH connection or command failed; see the admin audit log.")
873
+ for res, run in ((src, r), (dst, w)):
874
+ if run.learned_host_key:
875
+ res.config = {**res.config, "host_key": run.learned_host_key}
876
+ await db.commit()
877
+ digest = hashlib.sha256(body).hexdigest()
878
+ ev = await audit.record(db, tool="ssh_capture", decision="allow", agent=agent, session_id=sid, patterns=required,
879
+ detail={**detail, "matched": v.matched, "consumed": v.consumed_grants,
880
+ "request_ids": v.request_ids, "written": w.exit_code == 0, "bytes": len(body),
881
+ "sha256": digest, "write_exit_code": w.exit_code, "stderr_bytes": len(r.stderr)},
882
+ exit_code=r.exit_code, duration_ms=int((time.monotonic() - started) * 1000))
883
+ handle = _handle(v, ev)
884
+ if w.exit_code != 0:
885
+ return _error("put_failed", exit_code=w.exit_code, stderr=_excerpt(w.stderr), keywright=handle,
886
+ hint="The command succeeded but the destination refused the write.")
887
+ verb = "appended" if append else "wrote"
888
+ payload = {"written": detail["to_path"], "to_host": dst.name, "append": append, "bytes": len(body),
889
+ "sha256": digest, "source_exit_code": r.exit_code, "source_stderr": _excerpt(r.stderr), "keywright": handle}
890
+ return _result(payload, text=f"{verb} {len(body)} bytes from {src.name} to {dst.name}:{detail['to_path']} "
891
+ f"(sha256 {digest[:12]}); contents not returned\n" + _handle_text(handle) + "\n")
892
+
893
+
894
+ async def _proxy_call(db, agent: Agent, sid: str | None, server: str, tool: str, args: dict[str, Any]) -> types.CallToolResult:
895
+ res = (await db.execute(select(Resource).where(Resource.kind == "mcp", Resource.name == server))).scalar_one_or_none()
896
+ if res is None:
897
+ return _error("unknown_tool", name=f"{server}{mcp_proxy.SEP}{tool}")
898
+ # Keywright's own control keys ride alongside the upstream tool's arguments and are removed
899
+ # here, so the upstream sees exactly what the caller asked for, the classifier and the human
900
+ # see the same, and a client cannot break a strict upstream by mistake. They are underscored
901
+ # so they cannot collide with an upstream parameter (`wait_seconds` is a real one somewhere).
902
+ args = dict(args)
903
+ reason = ""
904
+ if isinstance(args.get("_reason"), str):
905
+ reason = args.pop("_reason")
906
+ wait_s = DEFAULT_WAIT
907
+ if "_wait_seconds" in args:
908
+ try:
909
+ wait_s = max(0, min(int(args.pop("_wait_seconds") or 0), MAX_WAIT))
910
+ except (TypeError, ValueError):
911
+ return _error("invalid_arguments", hint="_wait_seconds must be an integer number of seconds.")
912
+ knowledge_call = res.config.get("adapter") == "knowledge"
913
+ internal = res.config.get("adapter") in ("keywright", "knowledge")
914
+ if internal:
915
+ try:
916
+ inventory_args = (knowledge_mcp if knowledge_call else inventory_mcp).validate(tool, args)
917
+ except (inventory.InventoryError, knowledge.KnowledgeError) as exc:
918
+ return _error("invalid_arguments", hint=str(exc))
919
+ required = classify_mcp(server, tool, args, res.config.get("arg_keys"))
920
+ detail = {"server": server, "tool": tool, "arguments": args, "resource_id": res.id}
921
+ prefix = f"mcp/{server}/"
922
+ found = hazards.scan_mcp(tool, [p[len(prefix):] for p in required if p.startswith(prefix)],
923
+ res.config.get("hazard_tools"))
924
+ if found:
925
+ detail["hazards"] = hazards.as_dicts(found)
926
+ v, err = await _gate(db, agent, sid, required, reason, detail, wait_s, "mcp_call")
927
+ if err:
928
+ return err
929
+ await db.refresh(res)
930
+ started = time.monotonic()
931
+ try:
932
+ if internal:
933
+ # Start local write locks after approval, never while waiting for a human.
934
+ # The audit below commits both the changes and their attribution in one transaction.
935
+ await db.commit()
936
+ try:
937
+ if knowledge_call:
938
+ result = await knowledge_mcp.execute(db, res.id, agent, tool, inventory_args)
939
+ changes = []
940
+ else:
941
+ result, changes = await inventory_mcp.execute(db, tool, inventory_args)
942
+ out = _result(result)
943
+ if changes:
944
+ detail = {**detail, "changes": changes}
945
+ except (inventory.InventoryError, knowledge.KnowledgeError, IntegrityError) as exc:
946
+ await db.rollback()
947
+ await db.refresh(agent)
948
+ await db.refresh(res)
949
+ out = _error("knowledge_conflict" if knowledge_call else "inventory_conflict",
950
+ hint=str(exc) if isinstance(exc, (inventory.InventoryError, knowledge.KnowledgeError))
951
+ else "A conflicting record was saved. Read the current record and retry.")
952
+ else:
953
+ out = await mcp_proxy.call_tool(res.config, tool, args)
954
+ except Exception as e: # noqa: BLE001
955
+ if internal:
956
+ await db.rollback()
957
+ await db.refresh(agent)
958
+ await db.refresh(res)
959
+ await audit.record(db, tool="mcp_call", decision="error", agent=agent, session_id=sid, patterns=required,
960
+ detail={**detail, "error": (mcp_proxy.describe(e)
961
+ if res.config.get("result_inspection", "off") == "off"
962
+ else "Upstream failure; details suppressed by result inspection."),
963
+ "matched": v.matched, "request_ids": v.request_ids})
964
+ return _error("upstream_failed", message="Upstream call failed; see the admin audit log.")
965
+ upstream_is_error = bool(out.is_error)
966
+ out, inspection = await result_inspection.inspect(out, res.config)
967
+ try:
968
+ ev = await audit.record(db, tool="mcp_call", decision="allow", agent=agent, session_id=sid, patterns=required,
969
+ detail={**detail, "matched": v.matched, "consumed": v.consumed_grants,
970
+ "request_ids": v.request_ids,
971
+ "is_error": upstream_is_error, "result_delivery": inspection},
972
+ duration_ms=int((time.monotonic() - started) * 1000))
973
+ except Exception:
974
+ if internal:
975
+ await db.rollback()
976
+ await db.refresh(agent)
977
+ await db.refresh(res)
978
+ raise
979
+ if res.config.get("audit_handle", True):
980
+ # Add the trusted approval handle after result inspection.
981
+ handle = _handle(v, ev)
982
+ out = out.model_copy(update={
983
+ "meta": {**(out.meta or {}), "keywright": handle},
984
+ "content": [*out.content, types.TextContent(type="text", text=_handle_text(handle))],
985
+ })
986
+ return out
987
+
988
+
989
+ def build_server() -> Server:
990
+ return Server(
991
+ "keywright",
992
+ version=__version__,
993
+ instructions=(
994
+ "Keywright brokers access to SSH hosts, MCP servers and LLM gateways. Call access_list_resources first. "
995
+ "Gated tools ask the human for approval automatically; always pass a specific `reason`. "
996
+ "To ask for several tools at once use access_request with patterns; a segment may end in * "
997
+ "(mcp/<server>/tools/ha_get_*) and MCP calls can be qualified by argument (.../service=light.turn_on). "
998
+ "A 'session' is your MCP session id or the X-Keywright-Session header you send; without one, "
999
+ "session-scoped approvals are not available and grants are once or time-limited. "
1000
+ "If a call returns approval_required, wait with access_status or tell the user what you asked for. "
1001
+ "Automatic approvals keep the exact command/tool arguments for once, timed, session, and permanent "
1002
+ "access unless the human explicitly approves broader patterns. When you know your next SSH steps, "
1003
+ "ask for them together: access_request(operations=[{host, command}, {host, put, content}, "
1004
+ "{host, get}]) is one prompt and one exact grant per step. Prefer ssh_put/ssh_get over shell "
1005
+ "redirection for files: they classify by path and never put file contents in the prompt. "
1006
+ "A secret that only needs to reach another machine should go by ssh_capture, which delivers a "
1007
+ "command's output straight to a file on the destination host without returning it to you. "
1008
+ "Plain commands classify narrowly; loops, pipes and substitution need the host-wide ** pattern "
1009
+ "and are approved one command line at a time. Destructive operations (recursive deletes of "
1010
+ "system or home directories, formatting disks, destroying guests, volumes or pools, force "
1011
+ "pushes, powering off, touching key material or login configuration) are flagged whatever "
1012
+ "access you hold: the worst are refused outright, the rest always prompt the human and are "
1013
+ "approved once, exactly. Never reword a command to get around a flag; tell the user instead. "
1014
+ "Revocation blocks future calls; it does not stop running operations. "
1015
+ "Never retry a denied request without new instructions from the user. ssh_failed is not a "
1016
+ "decision: its `stage` (unreachable, hostkey, auth, exec) says where the connection broke, and "
1017
+ "auth or hostkey failures need the admin to enrol or re-probe the host, not another attempt. "
1018
+ "Only upstream TOOLS are proxied; upstream MCP resources (resources/read) and prompts are not "
1019
+ "available through Keywright, so use a server's tool equivalents instead. A proxied tool takes "
1020
+ "two optional control keys next to its own arguments, `_reason` (why, shown to the human) and "
1021
+ "`_wait_seconds` (how long to wait for approval); Keywright removes both before forwarding."
1022
+ ),
1023
+ on_list_tools=on_list_tools,
1024
+ on_call_tool=on_call_tool,
1025
+ )