agentflow-proof 1.0.0__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.
- agentflow/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
agentflow/runtime.py
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
"""Runtime config validation and readiness snapshots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import http.client
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import socket
|
|
12
|
+
import ssl
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
16
|
+
from urllib.parse import urljoin, urlsplit
|
|
17
|
+
|
|
18
|
+
IPAddress = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
|
19
|
+
|
|
20
|
+
from .artifacts import try_read_json, utc_now
|
|
21
|
+
from .contracts import (
|
|
22
|
+
ADAPTERS,
|
|
23
|
+
MCP_READINESS_CHECKS,
|
|
24
|
+
MCP_TRANSPORTS,
|
|
25
|
+
READINESS_CHECKS,
|
|
26
|
+
ROUTE_POLICIES,
|
|
27
|
+
RUNTIME_CONFIG_SCHEMA_VERSION,
|
|
28
|
+
RUNTIME_SNAPSHOT_SCHEMA_VERSION,
|
|
29
|
+
)
|
|
30
|
+
from .versioning import validate_schema_version
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256_path(path: Path) -> str:
|
|
34
|
+
digest = hashlib.sha256()
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
37
|
+
digest.update(chunk)
|
|
38
|
+
return digest.hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_runtime_config(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
42
|
+
findings: List[Dict[str, Any]] = []
|
|
43
|
+
for message in validate_schema_version(
|
|
44
|
+
config.get("schema_version"), RUNTIME_CONFIG_SCHEMA_VERSION, "runtime-config"
|
|
45
|
+
):
|
|
46
|
+
findings.append(
|
|
47
|
+
{
|
|
48
|
+
"id": "runtime_config_schema_version",
|
|
49
|
+
"severity": "error",
|
|
50
|
+
"message": message,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
runtimes = config.get("runtimes", {})
|
|
55
|
+
routes = config.get("routes", {})
|
|
56
|
+
if not isinstance(runtimes, dict):
|
|
57
|
+
findings.append(
|
|
58
|
+
{"id": "runtimes_invalid", "severity": "error", "message": "runtimes must be an object"}
|
|
59
|
+
)
|
|
60
|
+
runtimes = {}
|
|
61
|
+
if not isinstance(routes, dict):
|
|
62
|
+
findings.append(
|
|
63
|
+
{"id": "routes_invalid", "severity": "error", "message": "routes must be an object"}
|
|
64
|
+
)
|
|
65
|
+
routes = {}
|
|
66
|
+
|
|
67
|
+
default_runtime = config.get("default_runtime")
|
|
68
|
+
if isinstance(default_runtime, str) and default_runtime not in runtimes:
|
|
69
|
+
findings.append(
|
|
70
|
+
{
|
|
71
|
+
"id": "default_runtime_unknown",
|
|
72
|
+
"severity": "error",
|
|
73
|
+
"message": f"default_runtime {default_runtime} is not configured",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for runtime_id, runtime in runtimes.items():
|
|
78
|
+
if not isinstance(runtime, dict):
|
|
79
|
+
findings.append(
|
|
80
|
+
{
|
|
81
|
+
"id": "runtime_invalid",
|
|
82
|
+
"severity": "error",
|
|
83
|
+
"message": f"runtime {runtime_id} must be an object",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
adapter = runtime.get("adapter")
|
|
88
|
+
if adapter not in ADAPTERS:
|
|
89
|
+
findings.append(
|
|
90
|
+
{
|
|
91
|
+
"id": "adapter_unknown",
|
|
92
|
+
"severity": "error",
|
|
93
|
+
"message": f"runtime {runtime_id} has unknown adapter {adapter}",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
if not isinstance(runtime.get("enabled"), bool):
|
|
97
|
+
findings.append(
|
|
98
|
+
{
|
|
99
|
+
"id": "enabled_invalid",
|
|
100
|
+
"severity": "error",
|
|
101
|
+
"message": f"runtime {runtime_id}.enabled must be boolean",
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
readiness = runtime.get("readiness", {})
|
|
105
|
+
if isinstance(readiness, dict):
|
|
106
|
+
check = readiness.get("check", "command_exists")
|
|
107
|
+
if check not in READINESS_CHECKS:
|
|
108
|
+
findings.append(
|
|
109
|
+
{
|
|
110
|
+
"id": "readiness_check_unknown",
|
|
111
|
+
"severity": "error",
|
|
112
|
+
"message": f"runtime {runtime_id} has unknown readiness check {check}",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
capabilities = runtime.get("capabilities", {})
|
|
116
|
+
if isinstance(capabilities, dict):
|
|
117
|
+
for field in ("declared", "required"):
|
|
118
|
+
value = capabilities.get(field, [])
|
|
119
|
+
if field in capabilities and not isinstance(value, list):
|
|
120
|
+
findings.append(
|
|
121
|
+
{
|
|
122
|
+
"id": f"capabilities_{field}_invalid",
|
|
123
|
+
"severity": "error",
|
|
124
|
+
"message": (
|
|
125
|
+
f"runtime {runtime_id}.capabilities.{field} must be an array"
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
for route_name, route in routes.items():
|
|
131
|
+
if not isinstance(route, dict):
|
|
132
|
+
findings.append(
|
|
133
|
+
{
|
|
134
|
+
"id": "route_invalid",
|
|
135
|
+
"severity": "error",
|
|
136
|
+
"message": f"route {route_name} must be an object",
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
continue
|
|
140
|
+
primary = route.get("primary")
|
|
141
|
+
primary_lookup = True
|
|
142
|
+
if primary is None:
|
|
143
|
+
primary = default_runtime if isinstance(default_runtime, str) else "__missing__"
|
|
144
|
+
elif not isinstance(primary, str):
|
|
145
|
+
findings.append(
|
|
146
|
+
{
|
|
147
|
+
"id": "route_primary_invalid",
|
|
148
|
+
"severity": "error",
|
|
149
|
+
"message": f"route {route_name}.primary must be a string",
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
primary = "__missing__"
|
|
153
|
+
primary_lookup = False
|
|
154
|
+
if primary_lookup and primary not in runtimes:
|
|
155
|
+
findings.append(
|
|
156
|
+
{
|
|
157
|
+
"id": "route_primary_unknown",
|
|
158
|
+
"severity": "error",
|
|
159
|
+
"message": f"route {route_name}.primary references unknown runtime {primary}",
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
fallbacks = route.get("fallbacks", [])
|
|
163
|
+
valid_fallbacks = []
|
|
164
|
+
for fallback in fallbacks if isinstance(fallbacks, list) else []:
|
|
165
|
+
if not isinstance(fallback, str):
|
|
166
|
+
findings.append(
|
|
167
|
+
{
|
|
168
|
+
"id": "route_fallback_invalid",
|
|
169
|
+
"severity": "error",
|
|
170
|
+
"message": f"route {route_name}.fallbacks entries must be strings",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
continue
|
|
174
|
+
valid_fallbacks.append(fallback)
|
|
175
|
+
if fallback not in runtimes:
|
|
176
|
+
findings.append(
|
|
177
|
+
{
|
|
178
|
+
"id": "route_fallback_unknown",
|
|
179
|
+
"severity": "error",
|
|
180
|
+
"message": f"route {route_name}.fallbacks references unknown runtime {fallback}",
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
policy = route.get("policy", "manual_only")
|
|
184
|
+
if policy not in ROUTE_POLICIES:
|
|
185
|
+
findings.append(
|
|
186
|
+
{
|
|
187
|
+
"id": "route_policy_unknown",
|
|
188
|
+
"severity": "error",
|
|
189
|
+
"message": f"route {route_name}.policy is unknown: {policy}",
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
target_ids = [primary] + valid_fallbacks
|
|
193
|
+
declared = set()
|
|
194
|
+
for target_id in target_ids:
|
|
195
|
+
runtime = runtimes.get(target_id)
|
|
196
|
+
if isinstance(runtime, dict):
|
|
197
|
+
capabilities = runtime.get("capabilities", {})
|
|
198
|
+
if isinstance(capabilities, dict):
|
|
199
|
+
declared_values = capabilities.get("declared", [])
|
|
200
|
+
if isinstance(declared_values, list):
|
|
201
|
+
declared.update(item for item in declared_values if isinstance(item, str))
|
|
202
|
+
requires = route.get("requires", [])
|
|
203
|
+
if not isinstance(requires, list):
|
|
204
|
+
findings.append(
|
|
205
|
+
{
|
|
206
|
+
"id": "route_requires_invalid",
|
|
207
|
+
"severity": "error",
|
|
208
|
+
"message": f"route {route_name}.requires must be an array",
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
requires = []
|
|
212
|
+
required = set(item for item in requires if isinstance(item, str))
|
|
213
|
+
missing = sorted(required - declared)
|
|
214
|
+
if missing:
|
|
215
|
+
findings.append(
|
|
216
|
+
{
|
|
217
|
+
"id": "route_requires_unsatisfied",
|
|
218
|
+
"severity": "warning",
|
|
219
|
+
"message": (
|
|
220
|
+
f"route {route_name} requirements are not declared by "
|
|
221
|
+
f"targeted runtimes: {', '.join(missing)}"
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
primary_runtime = runtimes.get(primary)
|
|
226
|
+
if isinstance(primary_runtime, dict) and primary_runtime.get("enabled") is False:
|
|
227
|
+
findings.append(
|
|
228
|
+
{
|
|
229
|
+
"id": "route_primary_disabled",
|
|
230
|
+
"severity": "warning",
|
|
231
|
+
"message": f"route {route_name}.primary runtime {primary} is disabled",
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
mcp_servers = config.get("mcp_servers", {})
|
|
236
|
+
if not isinstance(mcp_servers, dict):
|
|
237
|
+
findings.append(
|
|
238
|
+
{
|
|
239
|
+
"id": "mcp_servers_invalid",
|
|
240
|
+
"severity": "error",
|
|
241
|
+
"message": "mcp_servers must be an object",
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
mcp_servers = {}
|
|
245
|
+
for server_id, server in mcp_servers.items():
|
|
246
|
+
if not isinstance(server, dict):
|
|
247
|
+
findings.append(
|
|
248
|
+
{
|
|
249
|
+
"id": "mcp_server_invalid",
|
|
250
|
+
"severity": "error",
|
|
251
|
+
"message": f"mcp server {server_id} must be an object",
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
if not isinstance(server.get("enabled"), bool):
|
|
256
|
+
findings.append(
|
|
257
|
+
{
|
|
258
|
+
"id": "mcp_enabled_invalid",
|
|
259
|
+
"severity": "error",
|
|
260
|
+
"message": f"mcp server {server_id}.enabled must be boolean",
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
transport = server.get("transport", "stdio")
|
|
264
|
+
if transport not in MCP_TRANSPORTS:
|
|
265
|
+
findings.append(
|
|
266
|
+
{
|
|
267
|
+
"id": "mcp_transport_unknown",
|
|
268
|
+
"severity": "error",
|
|
269
|
+
"message": f"mcp server {server_id} has unknown transport {transport}",
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
declared_tools = server.get("declared_tools", [])
|
|
273
|
+
if "declared_tools" in server and not isinstance(declared_tools, list):
|
|
274
|
+
findings.append(
|
|
275
|
+
{
|
|
276
|
+
"id": "mcp_declared_tools_invalid",
|
|
277
|
+
"severity": "error",
|
|
278
|
+
"message": f"mcp server {server_id}.declared_tools must be an array",
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
readiness = server.get("readiness", {})
|
|
282
|
+
if isinstance(readiness, dict):
|
|
283
|
+
check = readiness.get("check", "none")
|
|
284
|
+
if check not in MCP_READINESS_CHECKS:
|
|
285
|
+
findings.append(
|
|
286
|
+
{
|
|
287
|
+
"id": "mcp_readiness_check_unknown",
|
|
288
|
+
"severity": "error",
|
|
289
|
+
"message": f"mcp server {server_id} has unknown readiness check {check}",
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return findings
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _declared(runtime: Dict[str, Any]) -> List[str]:
|
|
297
|
+
capabilities = runtime.get("capabilities", {})
|
|
298
|
+
if not isinstance(capabilities, dict):
|
|
299
|
+
return []
|
|
300
|
+
declared = capabilities.get("declared", [])
|
|
301
|
+
if not isinstance(declared, list):
|
|
302
|
+
return []
|
|
303
|
+
return sorted(item for item in declared if isinstance(item, str))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _required(runtime: Dict[str, Any]) -> List[str]:
|
|
307
|
+
capabilities = runtime.get("capabilities", {})
|
|
308
|
+
if not isinstance(capabilities, dict):
|
|
309
|
+
return []
|
|
310
|
+
required = capabilities.get("required", [])
|
|
311
|
+
if not isinstance(required, list):
|
|
312
|
+
return []
|
|
313
|
+
return sorted(item for item in required if isinstance(item, str))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _command_available(command: Optional[str]) -> bool:
|
|
317
|
+
if not command:
|
|
318
|
+
return False
|
|
319
|
+
if os.path.isabs(command):
|
|
320
|
+
path = Path(command)
|
|
321
|
+
return path.exists() and os.access(path, os.X_OK)
|
|
322
|
+
return shutil.which(command) is not None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Ranges that front cloud instance-metadata services or "this host":
|
|
326
|
+
# AWS/GCP/Azure/Oracle/DO 169.254.169.254, Alibaba 100.100.100.200 (CGNAT),
|
|
327
|
+
# AWS IPv6 IMDS fd00:ec2::254, the 0.0.0.0/8 "this network" wildcard, and IPv6
|
|
328
|
+
# link-local. These are never legitimate LLM endpoints, so the opt-in `http`
|
|
329
|
+
# readiness probe refuses them while loopback/LAN model servers (ollama,
|
|
330
|
+
# LM Studio, self-hosted) stay allowed. This denylist is defense-in-depth: the
|
|
331
|
+
# user-configured models_url is the de-facto allowlist, and the probe pins to
|
|
332
|
+
# the validated IP (below) so it cannot be a general-purpose SSRF primitive.
|
|
333
|
+
_METADATA_BLOCK_NETS = (
|
|
334
|
+
ipaddress.ip_network("0.0.0.0/8"),
|
|
335
|
+
ipaddress.ip_network("169.254.0.0/16"),
|
|
336
|
+
ipaddress.ip_network("100.64.0.0/10"),
|
|
337
|
+
ipaddress.ip_network("fe80::/10"),
|
|
338
|
+
ipaddress.ip_network("fd00:ec2::254/128"),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
_MAX_PROBE_REDIRECTS = 5
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _addresses_to_check(ip: IPAddress) -> List[IPAddress]:
|
|
345
|
+
"""The address plus any IPv4 it embeds, so v6 encodings can't smuggle a
|
|
346
|
+
blocked v4 (`::ffff:169.254.169.254`, `::a9fe:a9fe`, 6to4, teredo)."""
|
|
347
|
+
checks: List[IPAddress] = [ip]
|
|
348
|
+
if isinstance(ip, ipaddress.IPv6Address):
|
|
349
|
+
if ip.ipv4_mapped:
|
|
350
|
+
checks.append(ip.ipv4_mapped)
|
|
351
|
+
if ip.sixtofour:
|
|
352
|
+
checks.append(ip.sixtofour)
|
|
353
|
+
if ip.teredo:
|
|
354
|
+
checks.append(ip.teredo[1])
|
|
355
|
+
# IPv4-compatible (deprecated ::/96), excluding :: and ::1.
|
|
356
|
+
if int(ip) >> 32 == 0 and int(ip) > 1:
|
|
357
|
+
checks.append(ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF))
|
|
358
|
+
return checks
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _ip_is_blocked(ip: IPAddress) -> bool:
|
|
362
|
+
for candidate in _addresses_to_check(ip):
|
|
363
|
+
for net in _METADATA_BLOCK_NETS:
|
|
364
|
+
if candidate.version == net.version and candidate in net:
|
|
365
|
+
return True
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _resolve_validated_ip(host: str, timeout: float) -> Tuple[Optional[IPAddress], Optional[str]]:
|
|
370
|
+
"""Resolve a host to one safe IP, or return a refusal reason.
|
|
371
|
+
|
|
372
|
+
The resolution result is what the probe then connects to (IP pinning), so
|
|
373
|
+
there is no second, unvalidated lookup — closing DNS-rebinding and
|
|
374
|
+
obfuscated-literal (decimal/hex/octal) bypasses. Resolution failure is
|
|
375
|
+
fail-closed: the probe refuses rather than letting a later lookup proceed
|
|
376
|
+
unchecked. A bounded DNS timeout prevents a hostile resolver from hanging
|
|
377
|
+
runtime-status.
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
resolved: List[IPAddress] = [ipaddress.ip_address(host)]
|
|
381
|
+
except ValueError:
|
|
382
|
+
previous = socket.getdefaulttimeout()
|
|
383
|
+
socket.setdefaulttimeout(timeout)
|
|
384
|
+
try:
|
|
385
|
+
resolved = [
|
|
386
|
+
ipaddress.ip_address(info[4][0].split("%", 1)[0])
|
|
387
|
+
for info in socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
388
|
+
]
|
|
389
|
+
except (socket.gaierror, OSError, ValueError) as exc:
|
|
390
|
+
return None, f"refusing probe: cannot resolve host {host!r}: {exc}"
|
|
391
|
+
finally:
|
|
392
|
+
socket.setdefaulttimeout(previous)
|
|
393
|
+
if not resolved:
|
|
394
|
+
return None, f"refusing probe: host {host!r} resolved to no addresses"
|
|
395
|
+
for ip in resolved:
|
|
396
|
+
if _ip_is_blocked(ip):
|
|
397
|
+
return None, f"refusing probe to instance-metadata/internal address: {ip}"
|
|
398
|
+
return resolved[0], None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _probe_once(url: str, timeout: float) -> Tuple[Optional[http.client.HTTPResponse], Optional[str]]:
|
|
402
|
+
"""Open one hop, connecting to the validated, pinned IP (not a re-resolve)."""
|
|
403
|
+
parts = urlsplit(url)
|
|
404
|
+
if parts.scheme not in ("http", "https"):
|
|
405
|
+
return None, f"refusing non-http(s) probe scheme: {url!r}"
|
|
406
|
+
host = parts.hostname
|
|
407
|
+
if not host:
|
|
408
|
+
return None, f"refusing probe: no host in {url!r}"
|
|
409
|
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
410
|
+
ip, reason = _resolve_validated_ip(host, timeout)
|
|
411
|
+
if reason:
|
|
412
|
+
return None, reason
|
|
413
|
+
raw = socket.create_connection((str(ip), port), timeout=timeout)
|
|
414
|
+
try:
|
|
415
|
+
if parts.scheme == "https":
|
|
416
|
+
context = ssl.create_default_context()
|
|
417
|
+
raw = context.wrap_socket(raw, server_hostname=host)
|
|
418
|
+
conn = http.client.HTTPConnection(host, port, timeout=timeout)
|
|
419
|
+
conn.sock = raw
|
|
420
|
+
target = parts.path or "/"
|
|
421
|
+
if parts.query:
|
|
422
|
+
target += "?" + parts.query
|
|
423
|
+
authority = host if port in (80, 443) else f"{host}:{port}"
|
|
424
|
+
conn.request("GET", target, headers={"Host": authority})
|
|
425
|
+
return conn.getresponse(), None
|
|
426
|
+
except Exception:
|
|
427
|
+
raw.close()
|
|
428
|
+
raise
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _http_models(url: str, timeout: float) -> Tuple[bool, List[str], str]:
|
|
432
|
+
if urlsplit(url).scheme not in ("http", "https"):
|
|
433
|
+
return False, [], f"refusing non-http(s) probe scheme: {url!r}"
|
|
434
|
+
current = url
|
|
435
|
+
try:
|
|
436
|
+
for _ in range(_MAX_PROBE_REDIRECTS + 1):
|
|
437
|
+
response, reason = _probe_once(current, timeout)
|
|
438
|
+
if reason:
|
|
439
|
+
return False, [], reason
|
|
440
|
+
try:
|
|
441
|
+
if 300 <= response.status < 400:
|
|
442
|
+
location = response.getheader("Location")
|
|
443
|
+
if not location:
|
|
444
|
+
return False, [], f"redirect ({response.status}) without Location"
|
|
445
|
+
# Re-validate every hop: a benign URL cannot redirect into a
|
|
446
|
+
# blocked range.
|
|
447
|
+
current = urljoin(current, location)
|
|
448
|
+
continue
|
|
449
|
+
if response.status < 200 or response.status >= 300:
|
|
450
|
+
return False, [], f"HTTP status {response.status}"
|
|
451
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
452
|
+
break
|
|
453
|
+
finally:
|
|
454
|
+
response.close()
|
|
455
|
+
else:
|
|
456
|
+
return False, [], f"refusing probe: more than {_MAX_PROBE_REDIRECTS} redirects"
|
|
457
|
+
except (OSError, ssl.SSLError, http.client.HTTPException, ValueError) as exc:
|
|
458
|
+
return False, [], str(exc)
|
|
459
|
+
models = []
|
|
460
|
+
for item in payload.get("data", []) if isinstance(payload, dict) else []:
|
|
461
|
+
model_id = item.get("id") if isinstance(item, dict) else None
|
|
462
|
+
if isinstance(model_id, str):
|
|
463
|
+
models.append(model_id)
|
|
464
|
+
return True, sorted(models), "HTTP probe succeeded"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _http_status(url: str, timeout: float) -> str:
|
|
468
|
+
"""Liveness-only probe for #19: one GET, status line only, body never read.
|
|
469
|
+
|
|
470
|
+
Returns "succeeded" (any HTTP status line, 3xx-5xx included -- the endpoint
|
|
471
|
+
answered), "refused" (scheme/SSRF/resolution guard blocked the probe), or
|
|
472
|
+
"failed" (connection error / timeout). Redirects are NOT followed: a
|
|
473
|
+
redirect status line already proves liveness. Raw error text (which can
|
|
474
|
+
embed host/IP/url) is discarded here so the snapshot layer only ever sees
|
|
475
|
+
the outcome enum (I5).
|
|
476
|
+
"""
|
|
477
|
+
try:
|
|
478
|
+
response, reason = _probe_once(url, timeout)
|
|
479
|
+
except (OSError, ssl.SSLError, http.client.HTTPException, ValueError):
|
|
480
|
+
return "failed"
|
|
481
|
+
if reason:
|
|
482
|
+
return "refused"
|
|
483
|
+
response.close()
|
|
484
|
+
return "succeeded"
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _spawn_liveness(command_args: List[str], grace: float) -> Tuple[bool, str]:
|
|
488
|
+
"""Treat a process that stays alive past grace as reachable."""
|
|
489
|
+
if not command_args:
|
|
490
|
+
return False, "no command configured for command_spawn"
|
|
491
|
+
try:
|
|
492
|
+
proc = subprocess.Popen(
|
|
493
|
+
command_args,
|
|
494
|
+
stdin=subprocess.PIPE,
|
|
495
|
+
stdout=subprocess.PIPE,
|
|
496
|
+
stderr=subprocess.PIPE,
|
|
497
|
+
)
|
|
498
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
499
|
+
return False, f"failed to spawn: {exc}"
|
|
500
|
+
try:
|
|
501
|
+
returncode = proc.wait(timeout=grace)
|
|
502
|
+
return returncode == 0, f"command exited {returncode}"
|
|
503
|
+
except subprocess.TimeoutExpired:
|
|
504
|
+
return True, "process alive after grace period"
|
|
505
|
+
finally:
|
|
506
|
+
if proc.poll() is None:
|
|
507
|
+
proc.terminate()
|
|
508
|
+
try:
|
|
509
|
+
proc.wait(timeout=2)
|
|
510
|
+
except subprocess.TimeoutExpired:
|
|
511
|
+
proc.kill()
|
|
512
|
+
proc.wait()
|
|
513
|
+
for pipe in (proc.stdin, proc.stdout, proc.stderr):
|
|
514
|
+
if pipe is not None:
|
|
515
|
+
pipe.close()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _normalized_declared_tools(server: Dict[str, Any]) -> List[str]:
|
|
519
|
+
# #19/P3: strings only, deduped, sorted -> stable snapshots and proof diffs.
|
|
520
|
+
tools = server.get("declared_tools", [])
|
|
521
|
+
if not isinstance(tools, list):
|
|
522
|
+
return []
|
|
523
|
+
return sorted({item for item in tools if isinstance(item, str)})
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _mcp_server_status(
|
|
527
|
+
server_id: str, server: Dict[str, Any], allow_probe: bool
|
|
528
|
+
) -> Dict[str, Any]:
|
|
529
|
+
"""Evidence-only MCP status per the #19 rule table.
|
|
530
|
+
|
|
531
|
+
Messages come from a closed vocabulary; probe targets (url/command/args)
|
|
532
|
+
are never echoed into the snapshot (I5). No MCP/JSON-RPC is ever spoken
|
|
533
|
+
(I1) -- liveness only.
|
|
534
|
+
"""
|
|
535
|
+
declared_tools = _normalized_declared_tools(server)
|
|
536
|
+
readiness = server.get("readiness", {})
|
|
537
|
+
if not isinstance(readiness, dict):
|
|
538
|
+
readiness = {}
|
|
539
|
+
# Absent readiness is declared-only ("not probed"); an explicit
|
|
540
|
+
# {"check": "none"} is a deliberate opt-out ("readiness check disabled").
|
|
541
|
+
declared_only = not readiness
|
|
542
|
+
check = readiness.get("check", "none")
|
|
543
|
+
enabled = server.get("enabled") is True
|
|
544
|
+
transport = server.get("transport", "stdio")
|
|
545
|
+
if transport not in MCP_TRANSPORTS:
|
|
546
|
+
transport = "stdio"
|
|
547
|
+
|
|
548
|
+
status = "configured"
|
|
549
|
+
reachable = False
|
|
550
|
+
check_status = "not_run"
|
|
551
|
+
message = "not probed"
|
|
552
|
+
probe_ran = False
|
|
553
|
+
|
|
554
|
+
if not enabled:
|
|
555
|
+
status = "unavailable"
|
|
556
|
+
check_status = "skipped"
|
|
557
|
+
message = "server disabled"
|
|
558
|
+
elif check == "none":
|
|
559
|
+
if not declared_only:
|
|
560
|
+
check_status = "not_applicable"
|
|
561
|
+
message = "readiness check disabled"
|
|
562
|
+
# declared-only stays not_run / "not probed"
|
|
563
|
+
elif check == "command_exists":
|
|
564
|
+
command = readiness.get("command")
|
|
565
|
+
if isinstance(command, str) and command:
|
|
566
|
+
probe_ran = True
|
|
567
|
+
reachable = _command_available(command)
|
|
568
|
+
status = "ready" if reachable else "unavailable"
|
|
569
|
+
check_status = "passed" if reachable else "failed"
|
|
570
|
+
message = "command found" if reachable else "command not found"
|
|
571
|
+
# no command -> stays configured / not probed
|
|
572
|
+
elif check == "command_spawn":
|
|
573
|
+
if allow_probe:
|
|
574
|
+
command = readiness.get("command")
|
|
575
|
+
args = readiness.get("args", [])
|
|
576
|
+
command_args = (
|
|
577
|
+
[command] + args
|
|
578
|
+
if isinstance(command, str) and isinstance(args, list)
|
|
579
|
+
else []
|
|
580
|
+
)
|
|
581
|
+
spawn_config = readiness.get("spawn", {})
|
|
582
|
+
if not isinstance(spawn_config, dict):
|
|
583
|
+
spawn_config = {}
|
|
584
|
+
try:
|
|
585
|
+
grace = float(spawn_config.get("grace_seconds", 0.5))
|
|
586
|
+
except (TypeError, ValueError):
|
|
587
|
+
grace = 0.5
|
|
588
|
+
probe_ran = True
|
|
589
|
+
reachable, _raw = _spawn_liveness(command_args, max(0.0, grace))
|
|
590
|
+
status = "ready" if reachable else "unavailable"
|
|
591
|
+
check_status = "passed" if reachable else "failed"
|
|
592
|
+
# fixed vocabulary: discard _spawn_liveness's raw message (I5)
|
|
593
|
+
message = "process alive after grace period" if reachable else "command exited"
|
|
594
|
+
else:
|
|
595
|
+
message = "command_spawn skipped without --probe"
|
|
596
|
+
elif check == "http_status":
|
|
597
|
+
if allow_probe:
|
|
598
|
+
url = readiness.get("url", "")
|
|
599
|
+
try:
|
|
600
|
+
timeout = float(readiness.get("timeout_seconds", 2))
|
|
601
|
+
except (TypeError, ValueError):
|
|
602
|
+
timeout = 2.0
|
|
603
|
+
probe_ran = True
|
|
604
|
+
outcome = _http_status(url if isinstance(url, str) else "", timeout)
|
|
605
|
+
reachable = outcome == "succeeded"
|
|
606
|
+
status = "ready" if reachable else "unavailable"
|
|
607
|
+
check_status = "passed" if reachable else "failed"
|
|
608
|
+
message = f"http status probe {outcome}"
|
|
609
|
+
else:
|
|
610
|
+
message = "http status probe skipped without --probe"
|
|
611
|
+
|
|
612
|
+
entry = {
|
|
613
|
+
"id": server_id,
|
|
614
|
+
"transport": transport,
|
|
615
|
+
"enabled": enabled,
|
|
616
|
+
"status": status,
|
|
617
|
+
"reachable": reachable,
|
|
618
|
+
"declared_tools": declared_tools,
|
|
619
|
+
"declared_tool_count": len(declared_tools),
|
|
620
|
+
"tool_source": "declared",
|
|
621
|
+
"checks": [
|
|
622
|
+
{
|
|
623
|
+
"name": check,
|
|
624
|
+
"status": check_status,
|
|
625
|
+
"severity": "info",
|
|
626
|
+
"message": message,
|
|
627
|
+
}
|
|
628
|
+
],
|
|
629
|
+
}
|
|
630
|
+
entry["_probe_ran"] = probe_ran # stripped by the caller after findings
|
|
631
|
+
return entry
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def build_runtime_status(
|
|
635
|
+
config_path: Path,
|
|
636
|
+
record_id: str,
|
|
637
|
+
allow_probe: bool = False,
|
|
638
|
+
) -> Dict[str, Any]:
|
|
639
|
+
config, read_error = try_read_json(config_path)
|
|
640
|
+
config_hash = sha256_path(config_path)
|
|
641
|
+
if config is None:
|
|
642
|
+
return {
|
|
643
|
+
"schema_version": RUNTIME_SNAPSHOT_SCHEMA_VERSION,
|
|
644
|
+
"id": record_id,
|
|
645
|
+
"created_at": utc_now(),
|
|
646
|
+
"runtime_config_sha256": config_hash,
|
|
647
|
+
"runtimes": [],
|
|
648
|
+
"routes": [],
|
|
649
|
+
"mcp_servers": [],
|
|
650
|
+
"findings": [
|
|
651
|
+
{
|
|
652
|
+
"id": "runtime_config_malformed",
|
|
653
|
+
"severity": "error",
|
|
654
|
+
"message": read_error,
|
|
655
|
+
}
|
|
656
|
+
],
|
|
657
|
+
}
|
|
658
|
+
findings = validate_runtime_config(config)
|
|
659
|
+
runtimes = config.get("runtimes", {})
|
|
660
|
+
routes = config.get("routes", {})
|
|
661
|
+
default_runtime = config.get("default_runtime")
|
|
662
|
+
if not isinstance(runtimes, dict):
|
|
663
|
+
runtimes = {}
|
|
664
|
+
if not isinstance(routes, dict):
|
|
665
|
+
routes = {}
|
|
666
|
+
runtime_statuses: List[Dict[str, Any]] = []
|
|
667
|
+
runtime_lookup: Dict[str, Dict[str, Any]] = {}
|
|
668
|
+
|
|
669
|
+
for runtime_id, runtime in sorted(runtimes.items()):
|
|
670
|
+
if not isinstance(runtime, dict):
|
|
671
|
+
continue
|
|
672
|
+
declared = _declared(runtime)
|
|
673
|
+
required = _required(runtime)
|
|
674
|
+
readiness = runtime.get("readiness", {})
|
|
675
|
+
if not isinstance(readiness, dict):
|
|
676
|
+
readiness = {}
|
|
677
|
+
check = readiness.get("check", "command_exists")
|
|
678
|
+
reachable = False
|
|
679
|
+
observed: List[str] = []
|
|
680
|
+
capability_source = "none"
|
|
681
|
+
check_name = check
|
|
682
|
+
severity = "info"
|
|
683
|
+
status = "configured"
|
|
684
|
+
message = "not probed"
|
|
685
|
+
check_status = "failed"
|
|
686
|
+
probe_skipped = False
|
|
687
|
+
|
|
688
|
+
if check == "none":
|
|
689
|
+
status = "configured"
|
|
690
|
+
message = "readiness check disabled"
|
|
691
|
+
check_status = "not_applicable"
|
|
692
|
+
elif check == "http":
|
|
693
|
+
if allow_probe:
|
|
694
|
+
http_config = readiness.get("http", {})
|
|
695
|
+
if not isinstance(http_config, dict):
|
|
696
|
+
http_config = {}
|
|
697
|
+
url = http_config.get("models_url", "")
|
|
698
|
+
try:
|
|
699
|
+
timeout = float(http_config.get("timeout_seconds", 2))
|
|
700
|
+
except (TypeError, ValueError):
|
|
701
|
+
timeout = 2.0
|
|
702
|
+
reachable, models, message = _http_models(url, timeout)
|
|
703
|
+
observed = sorted(set(models).intersection(declared))
|
|
704
|
+
capability_source = "probed"
|
|
705
|
+
else:
|
|
706
|
+
message = "http probe skipped without --probe"
|
|
707
|
+
check_status = "not_run"
|
|
708
|
+
probe_skipped = True
|
|
709
|
+
elif check == "http_status":
|
|
710
|
+
if allow_probe:
|
|
711
|
+
url = readiness.get("url", "")
|
|
712
|
+
try:
|
|
713
|
+
timeout = float(readiness.get("timeout_seconds", 2))
|
|
714
|
+
except (TypeError, ValueError):
|
|
715
|
+
timeout = 2.0
|
|
716
|
+
outcome = _http_status(url if isinstance(url, str) else "", timeout)
|
|
717
|
+
reachable = outcome == "succeeded"
|
|
718
|
+
message = f"http status probe {outcome}"
|
|
719
|
+
check_status = "passed" if reachable else "failed"
|
|
720
|
+
else:
|
|
721
|
+
message = "http status probe skipped without --probe"
|
|
722
|
+
check_status = "not_run"
|
|
723
|
+
probe_skipped = True
|
|
724
|
+
elif check == "command_spawn" and allow_probe:
|
|
725
|
+
command = readiness.get("command")
|
|
726
|
+
args = readiness.get("args", [])
|
|
727
|
+
command_args = [command] + args if isinstance(command, str) and isinstance(args, list) else []
|
|
728
|
+
spawn_config = readiness.get("spawn", {})
|
|
729
|
+
if not isinstance(spawn_config, dict):
|
|
730
|
+
spawn_config = {}
|
|
731
|
+
try:
|
|
732
|
+
grace = float(spawn_config.get("grace_seconds", 0.5))
|
|
733
|
+
except (TypeError, ValueError):
|
|
734
|
+
grace = 0.5
|
|
735
|
+
reachable, message = _spawn_liveness(command_args, max(0.0, grace))
|
|
736
|
+
else:
|
|
737
|
+
if check == "command_spawn":
|
|
738
|
+
check_name = "command_exists"
|
|
739
|
+
message = "command_spawn downgraded to command_exists without --probe"
|
|
740
|
+
command = readiness.get("command")
|
|
741
|
+
reachable = _command_available(command if isinstance(command, str) else None)
|
|
742
|
+
if check != "command_spawn":
|
|
743
|
+
message = "command exists" if reachable else "command not found"
|
|
744
|
+
|
|
745
|
+
if reachable:
|
|
746
|
+
if capability_source == "none":
|
|
747
|
+
observed = declared
|
|
748
|
+
capability_source = "declared"
|
|
749
|
+
missing = sorted(set(required) - set(observed))
|
|
750
|
+
status = "degraded" if missing else "ready"
|
|
751
|
+
check_status = "passed"
|
|
752
|
+
elif check != "none" and not probe_skipped:
|
|
753
|
+
status = "unavailable"
|
|
754
|
+
if runtime.get("enabled") is False:
|
|
755
|
+
reachable = False
|
|
756
|
+
observed = []
|
|
757
|
+
capability_source = "none"
|
|
758
|
+
status = "unavailable"
|
|
759
|
+
check_status = "skipped"
|
|
760
|
+
message = "runtime disabled"
|
|
761
|
+
|
|
762
|
+
runtime_entry = {
|
|
763
|
+
"id": runtime_id,
|
|
764
|
+
"adapter": runtime.get("adapter", "custom"),
|
|
765
|
+
"enabled": bool(runtime.get("enabled")),
|
|
766
|
+
"status": status,
|
|
767
|
+
"declared_capabilities": declared,
|
|
768
|
+
"observed_capabilities": observed,
|
|
769
|
+
"capability_source": capability_source,
|
|
770
|
+
"reachable": reachable,
|
|
771
|
+
"version": "unknown",
|
|
772
|
+
"checks": [
|
|
773
|
+
{
|
|
774
|
+
"name": check_name,
|
|
775
|
+
"status": check_status,
|
|
776
|
+
"severity": severity,
|
|
777
|
+
"message": message,
|
|
778
|
+
}
|
|
779
|
+
],
|
|
780
|
+
}
|
|
781
|
+
runtime_statuses.append(runtime_entry)
|
|
782
|
+
runtime_lookup[runtime_id] = runtime_entry
|
|
783
|
+
|
|
784
|
+
for entry in runtime_statuses:
|
|
785
|
+
if entry["status"] == "unavailable" and entry["enabled"]:
|
|
786
|
+
findings.append(
|
|
787
|
+
{
|
|
788
|
+
"id": "runtime_unavailable",
|
|
789
|
+
"severity": "warning",
|
|
790
|
+
"message": f"runtime {entry['id']} is unavailable",
|
|
791
|
+
}
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
route_statuses: List[Dict[str, Any]] = []
|
|
795
|
+
for role, route in sorted(routes.items()):
|
|
796
|
+
if not isinstance(route, dict):
|
|
797
|
+
continue
|
|
798
|
+
primary = route.get("primary")
|
|
799
|
+
if primary is None and isinstance(default_runtime, str):
|
|
800
|
+
primary = default_runtime
|
|
801
|
+
if not isinstance(primary, str):
|
|
802
|
+
primary = "__missing__"
|
|
803
|
+
primary_status = runtime_lookup.get(
|
|
804
|
+
primary, {"status": "unavailable", "observed_capabilities": []}
|
|
805
|
+
)
|
|
806
|
+
route_requires = route.get("requires", [])
|
|
807
|
+
if not isinstance(route_requires, list):
|
|
808
|
+
route_requires = []
|
|
809
|
+
required = sorted(item for item in route_requires if isinstance(item, str))
|
|
810
|
+
observed = set(primary_status.get("observed_capabilities", []))
|
|
811
|
+
missing = sorted(set(required) - observed)
|
|
812
|
+
route_entry = {
|
|
813
|
+
"role": role,
|
|
814
|
+
"primary": primary,
|
|
815
|
+
"status": (
|
|
816
|
+
"degraded"
|
|
817
|
+
if missing and primary_status.get("status") == "ready"
|
|
818
|
+
else primary_status.get("status", "unavailable")
|
|
819
|
+
),
|
|
820
|
+
"missing_capabilities": missing,
|
|
821
|
+
}
|
|
822
|
+
if route.get("allow_degraded") is True and route_entry["status"] == "degraded":
|
|
823
|
+
route_entry["status"] = "ready"
|
|
824
|
+
route_entry["allow_degraded_applied"] = True
|
|
825
|
+
route_statuses.append(route_entry)
|
|
826
|
+
if route.get("required") is True and route_entry["status"] == "unavailable":
|
|
827
|
+
findings.append(
|
|
828
|
+
{
|
|
829
|
+
"id": "required_route_unavailable",
|
|
830
|
+
"severity": "error",
|
|
831
|
+
"message": f"required route {role} is unavailable",
|
|
832
|
+
}
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
mcp_statuses: List[Dict[str, Any]] = []
|
|
836
|
+
mcp_servers = config.get("mcp_servers", {})
|
|
837
|
+
if not isinstance(mcp_servers, dict):
|
|
838
|
+
mcp_servers = {}
|
|
839
|
+
for server_id, server in sorted(mcp_servers.items()):
|
|
840
|
+
if not isinstance(server, dict):
|
|
841
|
+
continue
|
|
842
|
+
entry = _mcp_server_status(server_id, server, allow_probe)
|
|
843
|
+
probe_ran = entry.pop("_probe_ran")
|
|
844
|
+
if entry["enabled"] and probe_ran and entry["status"] == "unavailable":
|
|
845
|
+
findings.append(
|
|
846
|
+
{
|
|
847
|
+
"id": "mcp_server_unavailable",
|
|
848
|
+
"severity": "warning",
|
|
849
|
+
"message": f"mcp server {entry['id']} is unavailable",
|
|
850
|
+
}
|
|
851
|
+
)
|
|
852
|
+
mcp_statuses.append(entry)
|
|
853
|
+
|
|
854
|
+
return {
|
|
855
|
+
"schema_version": RUNTIME_SNAPSHOT_SCHEMA_VERSION,
|
|
856
|
+
"id": record_id,
|
|
857
|
+
"created_at": utc_now(),
|
|
858
|
+
"runtime_config_sha256": config_hash,
|
|
859
|
+
"runtimes": runtime_statuses,
|
|
860
|
+
"routes": route_statuses,
|
|
861
|
+
"mcp_servers": mcp_statuses,
|
|
862
|
+
"findings": findings,
|
|
863
|
+
}
|