agent-capmap 0.1.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.
- agent_capmap-0.1.0.dist-info/METADATA +295 -0
- agent_capmap-0.1.0.dist-info/RECORD +48 -0
- agent_capmap-0.1.0.dist-info/WHEEL +4 -0
- agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
- agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentcapmap/__init__.py +3 -0
- agentcapmap/__main__.py +5 -0
- agentcapmap/baseline.py +125 -0
- agentcapmap/classify/__init__.py +221 -0
- agentcapmap/classify/cache.py +74 -0
- agentcapmap/classify/prompt.py +160 -0
- agentcapmap/classify/provider.py +122 -0
- agentcapmap/classify/reference.py +115 -0
- agentcapmap/classify/reference_labels.json +6135 -0
- agentcapmap/cli.py +392 -0
- agentcapmap/diff.py +103 -0
- agentcapmap/discovery/__init__.py +209 -0
- agentcapmap/discovery/_common.py +297 -0
- agentcapmap/discovery/claude_code.py +258 -0
- agentcapmap/discovery/claude_desktop.py +28 -0
- agentcapmap/discovery/cursor.py +33 -0
- agentcapmap/discovery/mcp_json.py +29 -0
- agentcapmap/discovery/vscode.py +65 -0
- agentcapmap/enumerate/__init__.py +278 -0
- agentcapmap/enumerate/hashing.py +37 -0
- agentcapmap/enumerate/http.py +313 -0
- agentcapmap/enumerate/netguard.py +60 -0
- agentcapmap/enumerate/stdio.py +273 -0
- agentcapmap/enumerate/transport.py +86 -0
- agentcapmap/evaluation.py +498 -0
- agentcapmap/findings.py +129 -0
- agentcapmap/graph/__init__.py +264 -0
- agentcapmap/graph/server_attrs.py +164 -0
- agentcapmap/limits.py +35 -0
- agentcapmap/models.py +186 -0
- agentcapmap/policies/default.yaml +113 -0
- agentcapmap/policy/__init__.py +24 -0
- agentcapmap/policy/engine.py +229 -0
- agentcapmap/policy/loader.py +223 -0
- agentcapmap/py.typed +0 -0
- agentcapmap/redact.py +268 -0
- agentcapmap/report/__init__.py +36 -0
- agentcapmap/report/_text.py +62 -0
- agentcapmap/report/markdown.py +152 -0
- agentcapmap/report/sarif.py +209 -0
- agentcapmap/schemas/policy.schema.json +172 -0
- agentcapmap/schemas/tool-tags.schema.json +57 -0
- agentcapmap/taxonomy.py +56 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Canonical tool-definition hashing.
|
|
2
|
+
|
|
3
|
+
The hash identifies a tool definition independent of JSON key order or whitespace, so
|
|
4
|
+
it can key the classifier cache and detect rug pulls (definition changed after review).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def canonical_tool_definition(
|
|
16
|
+
name: str, description: str | None, input_schema: dict[str, Any] | None
|
|
17
|
+
) -> bytes:
|
|
18
|
+
payload = {
|
|
19
|
+
"name": name,
|
|
20
|
+
"description": description or "",
|
|
21
|
+
"inputSchema": input_schema or {},
|
|
22
|
+
}
|
|
23
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
24
|
+
"utf-8"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def tool_definition_hash(
|
|
29
|
+
name: str, description: str | None, input_schema: dict[str, Any] | None
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Hex SHA-256 of the canonical (name, description, inputSchema) JSON."""
|
|
32
|
+
return hashlib.sha256(canonical_tool_definition(name, description, input_schema)).hexdigest()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def server_definition_hash(tool_hashes: Iterable[str]) -> str:
|
|
36
|
+
"""Order-independent hash over a server's tool hashes."""
|
|
37
|
+
return hashlib.sha256("\n".join(sorted(tool_hashes)).encode("ascii")).hexdigest()
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""HTTP transports: Streamable HTTP (current spec) and legacy HTTP+SSE.
|
|
2
|
+
|
|
3
|
+
Security properties:
|
|
4
|
+
- Redirects are never followed, so configured credentials (Authorization, API-key
|
|
5
|
+
headers) cannot be forwarded to another host.
|
|
6
|
+
- The legacy SSE `endpoint` must be same-origin as the configured URL, for the same
|
|
7
|
+
reason.
|
|
8
|
+
- Response bodies, lines and SSE events are size-bounded.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
import queue
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Iterable, Iterator
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import urljoin, urlsplit
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from agentcapmap import __version__
|
|
24
|
+
from agentcapmap.enumerate.transport import (
|
|
25
|
+
AuthRequired,
|
|
26
|
+
IdCounter,
|
|
27
|
+
RequestTimeout,
|
|
28
|
+
TransportError,
|
|
29
|
+
check_allowed,
|
|
30
|
+
is_response_to,
|
|
31
|
+
unwrap_response,
|
|
32
|
+
)
|
|
33
|
+
from agentcapmap.limits import MAX_MESSAGE_BYTES, MAX_QUEUED_MESSAGES, loads_untrusted
|
|
34
|
+
from agentcapmap.limits import UntrustedJSONError as _BadJSON
|
|
35
|
+
from agentcapmap.redact import get_logger, redact_url
|
|
36
|
+
|
|
37
|
+
log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
_USER_AGENT = f"agent-capmap/{__version__}"
|
|
40
|
+
_DEFAULT_PORTS = {"http": 80, "https": 443}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def iter_sse(lines: Iterable[str], max_event_chars: int | None = None) -> Iterator[tuple[str, str]]:
|
|
44
|
+
"""Yield (event, data) pairs from a text/event-stream line iterator."""
|
|
45
|
+
max_event_chars = max_event_chars or MAX_MESSAGE_BYTES
|
|
46
|
+
event, data, size = "message", list[str](), 0
|
|
47
|
+
for line in lines:
|
|
48
|
+
if line == "":
|
|
49
|
+
if data:
|
|
50
|
+
yield event, "\n".join(data)
|
|
51
|
+
event, data, size = "message", [], 0
|
|
52
|
+
continue
|
|
53
|
+
if line.startswith(":"):
|
|
54
|
+
continue
|
|
55
|
+
field, _, value = line.partition(":")
|
|
56
|
+
value = value.removeprefix(" ")
|
|
57
|
+
if field == "event":
|
|
58
|
+
event = value
|
|
59
|
+
elif field == "data":
|
|
60
|
+
size += len(value) + 1
|
|
61
|
+
if size > max_event_chars:
|
|
62
|
+
raise TransportError(f"SSE event larger than {max_event_chars} bytes")
|
|
63
|
+
data.append(value)
|
|
64
|
+
if data:
|
|
65
|
+
yield event, "\n".join(data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _bounded_lines(response: httpx.Response) -> Iterator[str]:
|
|
69
|
+
"""Decode a streamed body into lines, refusing any line longer than the limit."""
|
|
70
|
+
max_line = MAX_MESSAGE_BYTES
|
|
71
|
+
buffer = ""
|
|
72
|
+
for chunk in response.iter_text():
|
|
73
|
+
buffer += chunk
|
|
74
|
+
while (newline := buffer.find("\n")) != -1:
|
|
75
|
+
yield buffer[:newline].removesuffix("\r")
|
|
76
|
+
buffer = buffer[newline + 1 :]
|
|
77
|
+
if len(buffer) > max_line:
|
|
78
|
+
raise TransportError(f"response line longer than {max_line} bytes")
|
|
79
|
+
if buffer:
|
|
80
|
+
yield buffer.removesuffix("\r")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_body(response: httpx.Response) -> bytes:
|
|
84
|
+
limit = MAX_MESSAGE_BYTES
|
|
85
|
+
declared = response.headers.get("content-length")
|
|
86
|
+
if declared and declared.isdigit() and int(declared) > limit:
|
|
87
|
+
raise TransportError(f"response body larger than {limit} bytes")
|
|
88
|
+
body = bytearray()
|
|
89
|
+
for chunk in response.iter_bytes():
|
|
90
|
+
body += chunk
|
|
91
|
+
if len(body) > limit:
|
|
92
|
+
raise TransportError(f"response body larger than {limit} bytes")
|
|
93
|
+
return bytes(body)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _raise_for_status(response: httpx.Response, url: str) -> None:
|
|
97
|
+
status = response.status_code
|
|
98
|
+
if status == 401:
|
|
99
|
+
raise AuthRequired(
|
|
100
|
+
f"HTTP 401 from {redact_url(url)}: authentication required",
|
|
101
|
+
response.headers.get("www-authenticate"),
|
|
102
|
+
)
|
|
103
|
+
if 300 <= status < 400:
|
|
104
|
+
raise TransportError(
|
|
105
|
+
f"HTTP {status} redirect from {redact_url(url)} refused "
|
|
106
|
+
"(credentials are never forwarded to another location)"
|
|
107
|
+
)
|
|
108
|
+
if status >= 400:
|
|
109
|
+
raise TransportError(f"HTTP {status} from {redact_url(url)}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _http_error(url: str, exc: httpx.HTTPError) -> TransportError:
|
|
113
|
+
# The exception text can embed the full URL (including secrets); report only its type.
|
|
114
|
+
return TransportError(f"HTTP error contacting {redact_url(url)}: {type(exc).__name__}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_json_messages(body: bytes) -> list[Any]:
|
|
118
|
+
try:
|
|
119
|
+
parsed = loads_untrusted(body)
|
|
120
|
+
except _BadJSON as exc:
|
|
121
|
+
raise TransportError(f"server returned invalid JSON ({exc})") from None
|
|
122
|
+
return parsed if isinstance(parsed, list) else [parsed]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
126
|
+
parts = urlsplit(url)
|
|
127
|
+
scheme = parts.scheme.lower()
|
|
128
|
+
return scheme, (parts.hostname or "").lower(), parts.port or _DEFAULT_PORTS.get(scheme)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _client(headers: dict[str, str], timeout: float) -> httpx.Client:
|
|
132
|
+
return httpx.Client(
|
|
133
|
+
headers={"User-Agent": _USER_AGENT, **headers},
|
|
134
|
+
timeout=httpx.Timeout(timeout),
|
|
135
|
+
follow_redirects=False,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class StreamableHttpTransport:
|
|
140
|
+
"""POST each JSON-RPC message; responses come back as JSON or as an SSE stream."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, url: str, headers: dict[str, str], timeout: float) -> None:
|
|
143
|
+
self._url = url
|
|
144
|
+
self._http = _client(headers, timeout)
|
|
145
|
+
self._ids = IdCounter()
|
|
146
|
+
self._session_id: str | None = None
|
|
147
|
+
self._protocol_version: str | None = None
|
|
148
|
+
|
|
149
|
+
def _headers(self) -> dict[str, str]:
|
|
150
|
+
headers = {
|
|
151
|
+
"Accept": "application/json, text/event-stream",
|
|
152
|
+
"Content-Type": "application/json",
|
|
153
|
+
}
|
|
154
|
+
if self._session_id:
|
|
155
|
+
headers["Mcp-Session-Id"] = self._session_id
|
|
156
|
+
if self._protocol_version:
|
|
157
|
+
headers["MCP-Protocol-Version"] = self._protocol_version
|
|
158
|
+
return headers
|
|
159
|
+
|
|
160
|
+
def _find_response(
|
|
161
|
+
self, response: httpx.Response, method: str, request_id: int, deadline: float
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
content_type = response.headers.get("content-type", "").split(";")[0].strip()
|
|
164
|
+
if content_type == "text/event-stream":
|
|
165
|
+
for _event, data in iter_sse(_bounded_lines(response)):
|
|
166
|
+
if time.monotonic() > deadline:
|
|
167
|
+
raise RequestTimeout(f"{method} timed out")
|
|
168
|
+
try:
|
|
169
|
+
message = loads_untrusted(data)
|
|
170
|
+
except _BadJSON:
|
|
171
|
+
continue
|
|
172
|
+
if is_response_to(message, request_id):
|
|
173
|
+
return unwrap_response(message)
|
|
174
|
+
raise TransportError(f"SSE stream ended without a response to {method}")
|
|
175
|
+
for message in _parse_json_messages(_read_body(response)):
|
|
176
|
+
if is_response_to(message, request_id):
|
|
177
|
+
return unwrap_response(message)
|
|
178
|
+
raise TransportError(f"no response to {method} in HTTP body")
|
|
179
|
+
|
|
180
|
+
def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
181
|
+
check_allowed(method)
|
|
182
|
+
request_id = self._ids.next()
|
|
183
|
+
body = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
|
|
184
|
+
deadline = time.monotonic() + timeout
|
|
185
|
+
try:
|
|
186
|
+
with self._http.stream(
|
|
187
|
+
"POST", self._url, json=body, headers=self._headers(), timeout=timeout
|
|
188
|
+
) as response:
|
|
189
|
+
_raise_for_status(response, self._url)
|
|
190
|
+
if session_id := response.headers.get("mcp-session-id"):
|
|
191
|
+
self._session_id = session_id
|
|
192
|
+
result = self._find_response(response, method, request_id, deadline)
|
|
193
|
+
except httpx.TimeoutException:
|
|
194
|
+
raise RequestTimeout(f"{method} timed out after {timeout:.0f}s") from None
|
|
195
|
+
except httpx.HTTPError as exc:
|
|
196
|
+
raise _http_error(self._url, exc) from None
|
|
197
|
+
|
|
198
|
+
if method == "initialize":
|
|
199
|
+
version = result.get("protocolVersion")
|
|
200
|
+
self._protocol_version = version if isinstance(version, str) else None
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
204
|
+
check_allowed(method, notification=True)
|
|
205
|
+
body: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
206
|
+
if params is not None:
|
|
207
|
+
body["params"] = params
|
|
208
|
+
try:
|
|
209
|
+
response = self._http.post(self._url, json=body, headers=self._headers())
|
|
210
|
+
except httpx.HTTPError as exc:
|
|
211
|
+
raise _http_error(self._url, exc) from None
|
|
212
|
+
_raise_for_status(response, self._url)
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
if self._session_id:
|
|
216
|
+
with contextlib.suppress(httpx.HTTPError):
|
|
217
|
+
self._http.delete(self._url, headers=self._headers(), timeout=5)
|
|
218
|
+
self._http.close()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class LegacySseTransport:
|
|
222
|
+
"""Deprecated 2024-11-05 transport: GET an SSE stream, POST to the advertised endpoint."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, url: str, headers: dict[str, str], timeout: float) -> None:
|
|
225
|
+
self._url = url
|
|
226
|
+
self._http = _client(headers, timeout)
|
|
227
|
+
self._ids = IdCounter()
|
|
228
|
+
self._messages: queue.Queue[Any] = queue.Queue(maxsize=MAX_QUEUED_MESSAGES)
|
|
229
|
+
self._endpoint: queue.Queue[str | BaseException] = queue.Queue()
|
|
230
|
+
self._closed = threading.Event()
|
|
231
|
+
threading.Thread(target=self._listen, daemon=True).start()
|
|
232
|
+
try:
|
|
233
|
+
item = self._endpoint.get(timeout=timeout)
|
|
234
|
+
except queue.Empty:
|
|
235
|
+
self.close()
|
|
236
|
+
raise RequestTimeout(f"no SSE endpoint event within {timeout:.0f}s") from None
|
|
237
|
+
if isinstance(item, BaseException):
|
|
238
|
+
self.close()
|
|
239
|
+
raise item
|
|
240
|
+
self._post_url: str = item
|
|
241
|
+
|
|
242
|
+
def _endpoint_url(self, data: str) -> str:
|
|
243
|
+
endpoint = urljoin(self._url, data.strip())
|
|
244
|
+
if _origin(endpoint) != _origin(self._url):
|
|
245
|
+
raise TransportError(
|
|
246
|
+
"SSE endpoint is on a different origin than the configured URL; refusing to "
|
|
247
|
+
"send requests (and credentials) there"
|
|
248
|
+
)
|
|
249
|
+
return endpoint
|
|
250
|
+
|
|
251
|
+
def _listen(self) -> None:
|
|
252
|
+
try:
|
|
253
|
+
with self._http.stream(
|
|
254
|
+
"GET",
|
|
255
|
+
self._url,
|
|
256
|
+
headers={"Accept": "text/event-stream"},
|
|
257
|
+
timeout=httpx.Timeout(10.0, read=None),
|
|
258
|
+
) as response:
|
|
259
|
+
_raise_for_status(response, self._url)
|
|
260
|
+
for event, data in iter_sse(_bounded_lines(response)):
|
|
261
|
+
if self._closed.is_set():
|
|
262
|
+
return
|
|
263
|
+
if event == "endpoint":
|
|
264
|
+
self._endpoint.put(self._endpoint_url(data))
|
|
265
|
+
elif event == "message":
|
|
266
|
+
with contextlib.suppress(_BadJSON):
|
|
267
|
+
self._messages.put(loads_untrusted(data))
|
|
268
|
+
except TransportError as exc:
|
|
269
|
+
self._endpoint.put(exc)
|
|
270
|
+
except httpx.HTTPError as exc:
|
|
271
|
+
if not self._closed.is_set():
|
|
272
|
+
self._endpoint.put(_http_error(self._url, exc))
|
|
273
|
+
finally:
|
|
274
|
+
with contextlib.suppress(queue.Full):
|
|
275
|
+
self._messages.put_nowait(None)
|
|
276
|
+
|
|
277
|
+
def _post(self, body: dict[str, Any]) -> None:
|
|
278
|
+
try:
|
|
279
|
+
response = self._http.post(
|
|
280
|
+
self._post_url, json=body, headers={"Content-Type": "application/json"}
|
|
281
|
+
)
|
|
282
|
+
except httpx.HTTPError as exc:
|
|
283
|
+
raise _http_error(self._url, exc) from None
|
|
284
|
+
_raise_for_status(response, self._post_url)
|
|
285
|
+
|
|
286
|
+
def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
287
|
+
check_allowed(method)
|
|
288
|
+
request_id = self._ids.next()
|
|
289
|
+
self._post({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
290
|
+
deadline = time.monotonic() + timeout
|
|
291
|
+
while True:
|
|
292
|
+
remaining = deadline - time.monotonic()
|
|
293
|
+
if remaining <= 0:
|
|
294
|
+
raise RequestTimeout(f"{method} timed out after {timeout:.0f}s")
|
|
295
|
+
try:
|
|
296
|
+
message = self._messages.get(timeout=remaining)
|
|
297
|
+
except queue.Empty:
|
|
298
|
+
continue
|
|
299
|
+
if message is None:
|
|
300
|
+
raise TransportError("SSE stream closed before response")
|
|
301
|
+
if is_response_to(message, request_id):
|
|
302
|
+
return unwrap_response(message)
|
|
303
|
+
|
|
304
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
305
|
+
check_allowed(method, notification=True)
|
|
306
|
+
body: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
307
|
+
if params is not None:
|
|
308
|
+
body["params"] = params
|
|
309
|
+
self._post(body)
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
self._closed.set()
|
|
313
|
+
self._http.close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Refuse remote MCP URLs that point at link-local or cloud-metadata addresses.
|
|
2
|
+
|
|
3
|
+
Loopback and private ranges are deliberately allowed: local MCP servers commonly
|
|
4
|
+
listen on localhost, and a config the user chose to enumerate may name a LAN host.
|
|
5
|
+
Link-local space (169.254.0.0/16, fe80::/10) holds the cloud instance-metadata
|
|
6
|
+
services, which hand out credentials and are never an MCP server.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ipaddress
|
|
12
|
+
import socket
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from agentcapmap.enumerate.transport import TransportError
|
|
16
|
+
|
|
17
|
+
_BLOCKED_NETWORKS = (
|
|
18
|
+
ipaddress.ip_network("169.254.0.0/16"), # AWS/Azure/GCP/OpenStack metadata, link-local
|
|
19
|
+
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
|
20
|
+
ipaddress.ip_network("fd00:ec2::/32"), # AWS IPv6 metadata
|
|
21
|
+
ipaddress.ip_network("100.100.100.200/32"), # Alibaba Cloud metadata
|
|
22
|
+
)
|
|
23
|
+
_BLOCKED_HOSTS = frozenset({"metadata.google.internal", "metadata", "instance-data"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _blocked(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
27
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
28
|
+
address = address.ipv4_mapped
|
|
29
|
+
return any(address in network for network in _BLOCKED_NETWORKS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_remote_url(url: str) -> None:
|
|
33
|
+
"""Raise TransportError if the URL's host is, or resolves to, a blocked address."""
|
|
34
|
+
host = (urlsplit(url).hostname or "").lower().rstrip(".")
|
|
35
|
+
if not host:
|
|
36
|
+
raise TransportError("remote server URL has no host")
|
|
37
|
+
if host in _BLOCKED_HOSTS:
|
|
38
|
+
raise TransportError("remote server host is a cloud metadata name; refusing to connect")
|
|
39
|
+
try:
|
|
40
|
+
literal = ipaddress.ip_address(host)
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
else:
|
|
44
|
+
if _blocked(literal):
|
|
45
|
+
raise TransportError("remote server address is link-local/metadata; refusing")
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
|
49
|
+
except OSError:
|
|
50
|
+
return # unresolvable: the connection attempt will fail with its own error
|
|
51
|
+
# SECURITY-REVIEW: this resolves the name once to check it, then httpx resolves it
|
|
52
|
+
# again to connect, so a DNS-rebinding server could still switch to a blocked
|
|
53
|
+
# address in between. Pinning the checked IP needs a custom httpx transport.
|
|
54
|
+
for info in infos:
|
|
55
|
+
try:
|
|
56
|
+
resolved = ipaddress.ip_address(info[4][0])
|
|
57
|
+
except ValueError:
|
|
58
|
+
continue
|
|
59
|
+
if _blocked(resolved):
|
|
60
|
+
raise TransportError("remote server host resolves to a link-local/metadata address")
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""stdio transport: launch the server as a subprocess, newline-delimited JSON-RPC.
|
|
2
|
+
|
|
3
|
+
Launching the configured server command is this module's purpose, which is why it
|
|
4
|
+
imports `subprocess` (bandit B404). It is reached only through the warned, opt-in
|
|
5
|
+
`--enumerate` flag.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import collections
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import queue
|
|
15
|
+
import shutil
|
|
16
|
+
import signal
|
|
17
|
+
import subprocess # nosec B404
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import IO, Any
|
|
23
|
+
|
|
24
|
+
from agentcapmap.enumerate.transport import (
|
|
25
|
+
JSONRPC_METHOD_NOT_FOUND,
|
|
26
|
+
IdCounter,
|
|
27
|
+
RequestTimeout,
|
|
28
|
+
TransportError,
|
|
29
|
+
check_allowed,
|
|
30
|
+
is_response_to,
|
|
31
|
+
unwrap_response,
|
|
32
|
+
)
|
|
33
|
+
from agentcapmap.limits import (
|
|
34
|
+
MAX_MESSAGE_BYTES,
|
|
35
|
+
MAX_QUEUED_MESSAGES,
|
|
36
|
+
MAX_STDERR_LINE_CHARS,
|
|
37
|
+
UntrustedJSONError,
|
|
38
|
+
loads_untrusted,
|
|
39
|
+
)
|
|
40
|
+
from agentcapmap.redact import get_logger, redact_text
|
|
41
|
+
|
|
42
|
+
log = get_logger(__name__)
|
|
43
|
+
|
|
44
|
+
_EOF = object()
|
|
45
|
+
_OVERLONG = object()
|
|
46
|
+
_STDERR_TAIL_LINES = 20
|
|
47
|
+
|
|
48
|
+
# Only these variables are inherited from our own environment, mirroring the official
|
|
49
|
+
# MCP SDK. Everything else (ANTHROPIC_API_KEY, cloud credentials, tokens) stays with us;
|
|
50
|
+
# a server gets extra variables only if its config sets them explicitly.
|
|
51
|
+
_INHERITED_ENV_WINDOWS = frozenset(
|
|
52
|
+
{
|
|
53
|
+
"APPDATA",
|
|
54
|
+
"COMSPEC",
|
|
55
|
+
"HOMEDRIVE",
|
|
56
|
+
"HOMEPATH",
|
|
57
|
+
"LOCALAPPDATA",
|
|
58
|
+
"PATH",
|
|
59
|
+
"PATHEXT",
|
|
60
|
+
"PROCESSOR_ARCHITECTURE",
|
|
61
|
+
"PROGRAMDATA",
|
|
62
|
+
"PROGRAMFILES",
|
|
63
|
+
"PROGRAMFILES(X86)",
|
|
64
|
+
"SYSTEMDRIVE",
|
|
65
|
+
"SYSTEMROOT",
|
|
66
|
+
"TEMP",
|
|
67
|
+
"TMP",
|
|
68
|
+
"USERNAME",
|
|
69
|
+
"USERPROFILE",
|
|
70
|
+
"WINDIR",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
_INHERITED_ENV_POSIX = frozenset(
|
|
74
|
+
{"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TERM", "TMPDIR", "USER"}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def default_environment() -> dict[str, str]:
|
|
79
|
+
"""The minimal environment a launched server inherits from agent-capmap."""
|
|
80
|
+
allowed = _INHERITED_ENV_WINDOWS if sys.platform == "win32" else _INHERITED_ENV_POSIX
|
|
81
|
+
return {
|
|
82
|
+
key: value
|
|
83
|
+
for key, value in os.environ.items()
|
|
84
|
+
# Skip exported shell functions ("() { ...") as the MCP SDK does.
|
|
85
|
+
if key.upper() in allowed and not value.startswith("()")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_bounded_line(stream: IO[bytes], limit: int) -> bytes | object | None:
|
|
90
|
+
"""Next line (None at EOF), or _OVERLONG after discarding a line longer than `limit`."""
|
|
91
|
+
chunk = stream.readline(limit)
|
|
92
|
+
if not chunk:
|
|
93
|
+
return None
|
|
94
|
+
if chunk.endswith(b"\n") or len(chunk) < limit:
|
|
95
|
+
return chunk
|
|
96
|
+
while True: # discard the rest of the oversized line
|
|
97
|
+
more = stream.readline(limit)
|
|
98
|
+
if not more or more.endswith(b"\n"):
|
|
99
|
+
return _OVERLONG
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StdioTransport:
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
command: str,
|
|
106
|
+
args: list[str],
|
|
107
|
+
env: dict[str, str],
|
|
108
|
+
cwd: str | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
full_env = {**default_environment(), **env}
|
|
111
|
+
executable = shutil.which(command, path=full_env.get("PATH")) or command
|
|
112
|
+
kwargs: dict[str, Any] = {}
|
|
113
|
+
if sys.platform == "win32":
|
|
114
|
+
kwargs["creationflags"] = (
|
|
115
|
+
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
|
|
116
|
+
)
|
|
117
|
+
else:
|
|
118
|
+
kwargs["start_new_session"] = True
|
|
119
|
+
try:
|
|
120
|
+
# Launching the configured server command is this transport's purpose, and is
|
|
121
|
+
# only reached via the explicit, warned --enumerate opt-in. The command comes
|
|
122
|
+
# from a config the user chose to enumerate; no shell is involved (argv list,
|
|
123
|
+
# shell=False), so arguments cannot be reinterpreted as shell syntax.
|
|
124
|
+
self._proc = subprocess.Popen( # noqa: S603 # nosec B603
|
|
125
|
+
[executable, *args],
|
|
126
|
+
stdin=subprocess.PIPE,
|
|
127
|
+
stdout=subprocess.PIPE,
|
|
128
|
+
stderr=subprocess.PIPE,
|
|
129
|
+
env=full_env,
|
|
130
|
+
cwd=cwd,
|
|
131
|
+
shell=False,
|
|
132
|
+
**kwargs,
|
|
133
|
+
)
|
|
134
|
+
except OSError as exc:
|
|
135
|
+
raise TransportError(f"failed to start {command!r}: {exc.strerror or exc}") from None
|
|
136
|
+
|
|
137
|
+
if self._proc.stdin is None or self._proc.stdout is None or self._proc.stderr is None:
|
|
138
|
+
_kill_tree(self._proc)
|
|
139
|
+
raise TransportError("failed to open pipes to the server process")
|
|
140
|
+
self._stdin = self._proc.stdin
|
|
141
|
+
self._ids = IdCounter()
|
|
142
|
+
self._messages: queue.Queue[Any] = queue.Queue(maxsize=MAX_QUEUED_MESSAGES)
|
|
143
|
+
self._stderr_tail: collections.deque[str] = collections.deque(maxlen=_STDERR_TAIL_LINES)
|
|
144
|
+
threading.Thread(target=self._read_stdout, args=(self._proc.stdout,), daemon=True).start()
|
|
145
|
+
threading.Thread(target=self._read_stderr, args=(self._proc.stderr,), daemon=True).start()
|
|
146
|
+
|
|
147
|
+
def _read_stdout(self, stream: IO[bytes]) -> None:
|
|
148
|
+
try:
|
|
149
|
+
while (raw := _read_bounded_line(stream, MAX_MESSAGE_BYTES)) is not None:
|
|
150
|
+
if not isinstance(raw, bytes): # _OVERLONG
|
|
151
|
+
log.debug("dropping stdout line longer than %d bytes", MAX_MESSAGE_BYTES)
|
|
152
|
+
continue
|
|
153
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
154
|
+
if not line:
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
message = loads_untrusted(line)
|
|
158
|
+
except UntrustedJSONError:
|
|
159
|
+
log.debug("ignoring non-JSON stdout line: %s", line[:200])
|
|
160
|
+
continue
|
|
161
|
+
self._messages.put(message)
|
|
162
|
+
except (OSError, ValueError):
|
|
163
|
+
pass # pipe closed underneath us
|
|
164
|
+
finally:
|
|
165
|
+
self._messages.put(_EOF)
|
|
166
|
+
|
|
167
|
+
def _read_stderr(self, stream: IO[bytes]) -> None:
|
|
168
|
+
try:
|
|
169
|
+
while (raw := _read_bounded_line(stream, MAX_MESSAGE_BYTES)) is not None:
|
|
170
|
+
if not isinstance(raw, bytes): # _OVERLONG
|
|
171
|
+
continue
|
|
172
|
+
text = raw.decode("utf-8", errors="replace").rstrip()[:MAX_STDERR_LINE_CHARS]
|
|
173
|
+
line = redact_text(text)
|
|
174
|
+
if line:
|
|
175
|
+
self._stderr_tail.append(line)
|
|
176
|
+
log.debug("server stderr: %s", line)
|
|
177
|
+
except (OSError, ValueError):
|
|
178
|
+
pass # pipe closed underneath us; stderr is diagnostic only
|
|
179
|
+
|
|
180
|
+
def _write(self, message: dict[str, Any]) -> None:
|
|
181
|
+
data = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
|
|
182
|
+
try:
|
|
183
|
+
self._stdin.write(data)
|
|
184
|
+
self._stdin.flush()
|
|
185
|
+
except OSError:
|
|
186
|
+
raise self._exited_error() from None
|
|
187
|
+
|
|
188
|
+
def _exited_error(self) -> TransportError:
|
|
189
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
190
|
+
self._proc.wait(timeout=1)
|
|
191
|
+
code = self._proc.returncode
|
|
192
|
+
detail = f"server exited (code {code})" if code is not None else "server closed stdout"
|
|
193
|
+
if self._stderr_tail:
|
|
194
|
+
detail += "; stderr: " + " | ".join(list(self._stderr_tail)[-5:])
|
|
195
|
+
return TransportError(redact_text(detail))
|
|
196
|
+
|
|
197
|
+
def _reply_to_server_request(self, message: dict[str, Any]) -> None:
|
|
198
|
+
# Servers may ping or ask for roots/sampling. We answer pings, decline the rest.
|
|
199
|
+
if message.get("method") == "ping":
|
|
200
|
+
self._write({"jsonrpc": "2.0", "id": message["id"], "result": {}})
|
|
201
|
+
else:
|
|
202
|
+
self._write(
|
|
203
|
+
{
|
|
204
|
+
"jsonrpc": "2.0",
|
|
205
|
+
"id": message["id"],
|
|
206
|
+
"error": {"code": JSONRPC_METHOD_NOT_FOUND, "message": "not supported"},
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def request(self, method: str, params: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
211
|
+
check_allowed(method)
|
|
212
|
+
request_id = self._ids.next()
|
|
213
|
+
self._write({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
214
|
+
deadline = time.monotonic() + timeout
|
|
215
|
+
while True:
|
|
216
|
+
remaining = deadline - time.monotonic()
|
|
217
|
+
if remaining <= 0:
|
|
218
|
+
raise RequestTimeout(f"{method} timed out after {timeout:.0f}s")
|
|
219
|
+
try:
|
|
220
|
+
message = self._messages.get(timeout=remaining)
|
|
221
|
+
except queue.Empty:
|
|
222
|
+
continue
|
|
223
|
+
if message is _EOF:
|
|
224
|
+
raise self._exited_error()
|
|
225
|
+
if is_response_to(message, request_id):
|
|
226
|
+
return unwrap_response(message)
|
|
227
|
+
if isinstance(message, dict) and "method" in message and "id" in message:
|
|
228
|
+
self._reply_to_server_request(message)
|
|
229
|
+
# Anything else (notifications, stray responses) is ignored.
|
|
230
|
+
|
|
231
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
232
|
+
check_allowed(method, notification=True)
|
|
233
|
+
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
234
|
+
if params is not None:
|
|
235
|
+
message["params"] = params
|
|
236
|
+
self._write(message)
|
|
237
|
+
|
|
238
|
+
def close(self) -> None:
|
|
239
|
+
# Spec shutdown: close stdin, give the server a moment, then terminate the tree.
|
|
240
|
+
with contextlib.suppress(OSError):
|
|
241
|
+
self._stdin.close()
|
|
242
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
243
|
+
self._proc.wait(timeout=2)
|
|
244
|
+
# Always kill the tree: children (e.g. npx -> node) may outlive the parent.
|
|
245
|
+
_kill_tree(self._proc)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _taskkill_path() -> str:
|
|
249
|
+
system_root = os.environ.get("SYSTEMROOT") or r"C:\Windows"
|
|
250
|
+
return str(Path(system_root) / "System32" / "taskkill.exe")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _kill_tree(proc: subprocess.Popen[bytes]) -> None:
|
|
254
|
+
if sys.platform == "win32":
|
|
255
|
+
# Fixed system binary by absolute path; the only variable argument is our own
|
|
256
|
+
# child's integer PID.
|
|
257
|
+
subprocess.run( # noqa: S603 # nosec B603
|
|
258
|
+
[_taskkill_path(), "/F", "/T", "/PID", str(int(proc.pid))],
|
|
259
|
+
stdout=subprocess.DEVNULL,
|
|
260
|
+
stderr=subprocess.DEVNULL,
|
|
261
|
+
check=False,
|
|
262
|
+
shell=False,
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
266
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
267
|
+
try:
|
|
268
|
+
proc.wait(timeout=2)
|
|
269
|
+
except subprocess.TimeoutExpired:
|
|
270
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
271
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
272
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
273
|
+
proc.wait(timeout=2)
|