chp-adapter-http 0.10.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.
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""chp-adapter-http — governed HTTP client as a CHP capability.
|
|
2
|
+
|
|
3
|
+
One capability:
|
|
4
|
+
|
|
5
|
+
* ``request`` — make an HTTP request with optional URL origin allowlist.
|
|
6
|
+
Request header values and response body absent from evidence.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
11
|
+
from chp_adapter_http import HttpAdapter, HttpConfig
|
|
12
|
+
|
|
13
|
+
host = LocalCapabilityHost()
|
|
14
|
+
register_adapter(host, HttpAdapter(HttpConfig(
|
|
15
|
+
allowed_origins=["https://api.example.com"],
|
|
16
|
+
)))
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .adapter import HttpAdapter, HttpConfig
|
|
22
|
+
|
|
23
|
+
__all__ = ["HttpAdapter", "HttpConfig"]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""HttpAdapter — governed HTTP client as a CHP capability.
|
|
2
|
+
|
|
3
|
+
Safety invariants (MUST PRESERVE):
|
|
4
|
+
* Every URL's origin (scheme + host) must match an entry in ``allowed_origins``
|
|
5
|
+
if the list is non-None.
|
|
6
|
+
* Request header VALUES never in evidence (may contain ``Authorization``).
|
|
7
|
+
* Response body never in evidence (may contain PII/credentials); only
|
|
8
|
+
``body_length`` and ``content_type`` are recorded.
|
|
9
|
+
* Fresh ``httpx.AsyncClient`` per call — loop-safe, no connection reuse issues.
|
|
10
|
+
|
|
11
|
+
One capability: ``request``
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import ipaddress
|
|
18
|
+
import socket
|
|
19
|
+
import threading
|
|
20
|
+
import time as _time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
# Cloud metadata / link-local endpoints — never a legitimate HTTP target, the
|
|
28
|
+
# primary SSRF escalation. Blocked by default even without an allowlist. Does
|
|
29
|
+
# NOT include general loopback/RFC-1918/CGNAT: this adapter legitimately calls
|
|
30
|
+
# localhost sovereign inference (vLLM/TEI/scout) and Tailscale 100.64/10 mesh
|
|
31
|
+
# nodes — set block_private_networks=True to sandbox those too.
|
|
32
|
+
_METADATA_HOSTS = frozenset({
|
|
33
|
+
"169.254.169.254", # AWS/GCP/Azure IMDS
|
|
34
|
+
"169.254.170.2", # ECS task metadata
|
|
35
|
+
"fd00:ec2::254", # EC2 IMDSv2 IPv6
|
|
36
|
+
"metadata.google.internal",
|
|
37
|
+
"metadata",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
from chp_core import BaseAdapter, capability
|
|
41
|
+
|
|
42
|
+
_EMITS = ["http_request", "http_response", "http_error", "http_circuit_open"]
|
|
43
|
+
|
|
44
|
+
_ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class HttpConfig:
|
|
49
|
+
"""Config for HttpAdapter.
|
|
50
|
+
|
|
51
|
+
``allowed_origins`` — if non-None, every URL must start with one of these
|
|
52
|
+
origin strings (``scheme://host[:port]``). Use to sandbox the adapter to
|
|
53
|
+
known hosts.
|
|
54
|
+
|
|
55
|
+
``default_headers`` — merged with per-request headers (request wins on conflict).
|
|
56
|
+
|
|
57
|
+
``transport`` accepts an ``httpx.MockTransport`` for tests.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
allowed_origins: list[str] | None = None
|
|
61
|
+
default_headers: dict[str, str] = field(default_factory=dict)
|
|
62
|
+
timeout: float = 30.0
|
|
63
|
+
max_response_bytes: int = 1 * 1024 * 1024 # 1 MB
|
|
64
|
+
transport: Any = None
|
|
65
|
+
# When True, additionally deny loopback / private / link-local / CGNAT
|
|
66
|
+
# targets (resolving hostnames first). Off by default because sovereign
|
|
67
|
+
# inference (localhost) and mesh (Tailscale 100.64/10) depend on them.
|
|
68
|
+
block_private_networks: bool = False
|
|
69
|
+
# Resilience: retries + circuit breaker key off TRANSPORT failures (server
|
|
70
|
+
# down, connection refused, timeout) — NOT HTTP status codes (those return
|
|
71
|
+
# normally as before). Composing adapters (tei/vllm/github/local_llm) inherit
|
|
72
|
+
# this for free.
|
|
73
|
+
max_retries: int = 2 # extra attempts after the first, on transport error
|
|
74
|
+
backoff_base: float = 0.3 # seconds; delay = backoff_base * 2**attempt
|
|
75
|
+
circuit_threshold: int = 5 # consecutive transport failures (per origin) to open
|
|
76
|
+
circuit_cooldown: float = 30.0 # seconds the circuit stays open, failing fast
|
|
77
|
+
|
|
78
|
+
def _check_url(self, url: str) -> str:
|
|
79
|
+
"""Return the URL unchanged if allowed; raise PermissionError if not."""
|
|
80
|
+
parsed = urlparse(url)
|
|
81
|
+
host = (parsed.hostname or "").lower()
|
|
82
|
+
|
|
83
|
+
# 1. Always deny cloud metadata endpoints (SSRF crown jewel).
|
|
84
|
+
if host in _METADATA_HOSTS:
|
|
85
|
+
raise PermissionError(f"host {host!r} is a blocked metadata endpoint")
|
|
86
|
+
|
|
87
|
+
# 2. Optional strict sandbox: deny loopback/private/link-local/CGNAT.
|
|
88
|
+
if self.block_private_networks:
|
|
89
|
+
for addr in self._resolve_ips(host, parsed.port):
|
|
90
|
+
if (
|
|
91
|
+
addr.is_private or addr.is_loopback or addr.is_link_local
|
|
92
|
+
or addr.is_reserved or addr.is_multicast
|
|
93
|
+
):
|
|
94
|
+
raise PermissionError(f"host {host!r} resolves to blocked address {addr}")
|
|
95
|
+
|
|
96
|
+
# 3. Origin allowlist — strict scheme+host[:port] equality (no prefix
|
|
97
|
+
# match: 'https://evil.com' must not admit 'https://evil.com.attacker').
|
|
98
|
+
if self.allowed_origins is not None:
|
|
99
|
+
origin = f"{parsed.scheme}://{parsed.netloc}"
|
|
100
|
+
allowed_set = {a.rstrip("/") for a in self.allowed_origins}
|
|
101
|
+
if origin not in allowed_set:
|
|
102
|
+
raise PermissionError(f"URL origin {origin!r} is not in allowed_origins")
|
|
103
|
+
return url
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _resolve_ips(host: str, port: int | None) -> list[ipaddress._BaseAddress]:
|
|
107
|
+
"""Resolve host to IPs. A bare IP literal skips DNS. Unresolvable hosts
|
|
108
|
+
raise (fail-closed) only in strict mode where this is called."""
|
|
109
|
+
try:
|
|
110
|
+
return [ipaddress.ip_address(host)]
|
|
111
|
+
except ValueError:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
infos = socket.getaddrinfo(host, port or 80, proto=socket.IPPROTO_TCP)
|
|
115
|
+
except socket.gaierror as exc:
|
|
116
|
+
raise PermissionError(f"host {host!r} could not be resolved") from exc
|
|
117
|
+
return [ipaddress.ip_address(info[4][0]) for info in infos]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class HttpAdapter(BaseAdapter):
|
|
121
|
+
"""Generic governed HTTP client."""
|
|
122
|
+
|
|
123
|
+
adapter_id = "chp.adapters.http"
|
|
124
|
+
adapter_name = "HTTP"
|
|
125
|
+
adapter_description = "Make governed HTTP requests with optional URL origin allowlist."
|
|
126
|
+
adapter_category = "execution"
|
|
127
|
+
adapter_tags = ["http", "client", "api", "execution"]
|
|
128
|
+
|
|
129
|
+
def __init__(self, config: HttpConfig | None = None) -> None:
|
|
130
|
+
self._config = config or HttpConfig()
|
|
131
|
+
# Per-origin circuit state: {origin: {"failures": int, "opened_until": monotonic}}
|
|
132
|
+
self._circuit: dict[str, dict[str, float]] = {}
|
|
133
|
+
self._circuit_lock = threading.Lock()
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _origin(url: str) -> str:
|
|
137
|
+
p = urlparse(url)
|
|
138
|
+
return f"{p.scheme}://{p.netloc}"
|
|
139
|
+
|
|
140
|
+
def _circuit_is_open(self, origin: str) -> bool:
|
|
141
|
+
with self._circuit_lock:
|
|
142
|
+
st = self._circuit.get(origin)
|
|
143
|
+
return bool(st and st["opened_until"] > _time.monotonic())
|
|
144
|
+
|
|
145
|
+
def _circuit_record(self, origin: str, *, success: bool) -> None:
|
|
146
|
+
with self._circuit_lock:
|
|
147
|
+
st = self._circuit.setdefault(origin, {"failures": 0.0, "opened_until": 0.0})
|
|
148
|
+
if success:
|
|
149
|
+
st["failures"] = 0.0
|
|
150
|
+
st["opened_until"] = 0.0
|
|
151
|
+
else:
|
|
152
|
+
st["failures"] += 1
|
|
153
|
+
if st["failures"] >= self._config.circuit_threshold:
|
|
154
|
+
st["opened_until"] = _time.monotonic() + self._config.circuit_cooldown
|
|
155
|
+
|
|
156
|
+
@capability(
|
|
157
|
+
id="chp.adapters.http.request",
|
|
158
|
+
version="1.0.0",
|
|
159
|
+
description="Make an HTTP request.",
|
|
160
|
+
category="execution",
|
|
161
|
+
risk="medium",
|
|
162
|
+
input_schema={
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"method": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"enum": list(_ALLOWED_METHODS),
|
|
168
|
+
"description": "HTTP method.",
|
|
169
|
+
},
|
|
170
|
+
"url": {"type": "string", "description": "Full URL to request."},
|
|
171
|
+
"headers": {
|
|
172
|
+
"type": "object",
|
|
173
|
+
"description": "Additional request headers.",
|
|
174
|
+
"additionalProperties": {"type": "string"},
|
|
175
|
+
},
|
|
176
|
+
"body": {"type": "string", "description": "Plain-text request body."},
|
|
177
|
+
"json_body": {"description": "JSON request body (serialized as application/json)."},
|
|
178
|
+
"params": {
|
|
179
|
+
"type": "object",
|
|
180
|
+
"description": "URL query parameters.",
|
|
181
|
+
"additionalProperties": {"type": "string"},
|
|
182
|
+
},
|
|
183
|
+
"timeout": {
|
|
184
|
+
"type": "number",
|
|
185
|
+
"minimum": 0.1,
|
|
186
|
+
"description": "Per-request timeout override (seconds).",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"required": ["method", "url"],
|
|
190
|
+
"additionalProperties": False,
|
|
191
|
+
},
|
|
192
|
+
emits=_EMITS,
|
|
193
|
+
tags=["http", "client"],
|
|
194
|
+
)
|
|
195
|
+
async def request(self, ctx: Any, payload: dict) -> dict:
|
|
196
|
+
method = payload["method"].upper()
|
|
197
|
+
url = payload["url"]
|
|
198
|
+
req_headers = payload.get("headers") or {}
|
|
199
|
+
body = payload.get("body")
|
|
200
|
+
json_body = payload.get("json_body")
|
|
201
|
+
params = payload.get("params") or {}
|
|
202
|
+
timeout = float(payload.get("timeout") or self._config.timeout)
|
|
203
|
+
|
|
204
|
+
# URL allowlist check
|
|
205
|
+
try:
|
|
206
|
+
self._config._check_url(url)
|
|
207
|
+
except PermissionError as exc:
|
|
208
|
+
ctx.emit("http_error", {
|
|
209
|
+
"reason": "url_not_allowed",
|
|
210
|
+
"url": url,
|
|
211
|
+
"error": str(exc),
|
|
212
|
+
}, redacted=False)
|
|
213
|
+
raise
|
|
214
|
+
|
|
215
|
+
# Merge headers: default first, then per-request
|
|
216
|
+
merged_headers = {**self._config.default_headers, **req_headers}
|
|
217
|
+
|
|
218
|
+
ctx.emit("http_request", {
|
|
219
|
+
"method": method,
|
|
220
|
+
"url": url,
|
|
221
|
+
"header_keys": sorted(merged_headers.keys()),
|
|
222
|
+
# header values intentionally not recorded
|
|
223
|
+
"has_body": body is not None or json_body is not None,
|
|
224
|
+
"param_keys": sorted(params.keys()),
|
|
225
|
+
}, redacted=False)
|
|
226
|
+
|
|
227
|
+
# Circuit breaker: fail fast if this origin is currently tripped.
|
|
228
|
+
origin = self._origin(url)
|
|
229
|
+
if self._circuit_is_open(origin):
|
|
230
|
+
ctx.emit("http_circuit_open", {
|
|
231
|
+
"method": method, "url": url, "origin": origin,
|
|
232
|
+
}, redacted=False)
|
|
233
|
+
raise RuntimeError(f"circuit open for {origin!r} — failing fast (recent transport failures)")
|
|
234
|
+
|
|
235
|
+
t0 = _time.monotonic()
|
|
236
|
+
attempts = self._config.max_retries + 1
|
|
237
|
+
resp = None
|
|
238
|
+
for attempt in range(attempts):
|
|
239
|
+
try:
|
|
240
|
+
async with httpx.AsyncClient(
|
|
241
|
+
timeout=timeout,
|
|
242
|
+
transport=self._config.transport,
|
|
243
|
+
) as client:
|
|
244
|
+
resp = await client.request(
|
|
245
|
+
method,
|
|
246
|
+
url,
|
|
247
|
+
headers=merged_headers or None,
|
|
248
|
+
content=body.encode() if body else None,
|
|
249
|
+
json=json_body,
|
|
250
|
+
params=params or None,
|
|
251
|
+
)
|
|
252
|
+
self._circuit_record(origin, success=True)
|
|
253
|
+
break
|
|
254
|
+
except httpx.HTTPError as exc:
|
|
255
|
+
if attempt + 1 < attempts:
|
|
256
|
+
await asyncio.sleep(self._config.backoff_base * (2 ** attempt))
|
|
257
|
+
continue
|
|
258
|
+
self._circuit_record(origin, success=False)
|
|
259
|
+
duration_ms = int((_time.monotonic() - t0) * 1000)
|
|
260
|
+
ctx.emit("http_error", {
|
|
261
|
+
"method": method,
|
|
262
|
+
"url": url,
|
|
263
|
+
"reason": type(exc).__name__,
|
|
264
|
+
"error": str(exc)[:200],
|
|
265
|
+
"attempts": attempt + 1,
|
|
266
|
+
"duration_ms": duration_ms,
|
|
267
|
+
}, redacted=False)
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
duration_ms = int((_time.monotonic() - t0) * 1000)
|
|
271
|
+
|
|
272
|
+
raw_bytes = resp.content
|
|
273
|
+
truncated = len(raw_bytes) > self._config.max_response_bytes
|
|
274
|
+
if truncated:
|
|
275
|
+
raw_bytes = raw_bytes[: self._config.max_response_bytes]
|
|
276
|
+
|
|
277
|
+
content_type = resp.headers.get("content-type", "")
|
|
278
|
+
body_str = raw_bytes.decode(errors="replace")
|
|
279
|
+
|
|
280
|
+
# Attempt JSON parse
|
|
281
|
+
json_data = None
|
|
282
|
+
if "json" in content_type:
|
|
283
|
+
try:
|
|
284
|
+
import json as _json
|
|
285
|
+
json_data = _json.loads(raw_bytes)
|
|
286
|
+
except Exception:
|
|
287
|
+
json_data = None # best-effort parse; leave None on malformed JSON
|
|
288
|
+
|
|
289
|
+
# Extract token usage from OpenAI-compatible responses (usage{} key)
|
|
290
|
+
_usage = json_data.get("usage") if isinstance(json_data, dict) else None
|
|
291
|
+
_model = (
|
|
292
|
+
(json_data.get("model") if isinstance(json_data, dict) else None)
|
|
293
|
+
or (json_body or {}).get("model")
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
ctx.emit("http_response", {
|
|
297
|
+
"method": method,
|
|
298
|
+
"url": str(resp.url),
|
|
299
|
+
"status_code": resp.status_code,
|
|
300
|
+
"content_type": content_type,
|
|
301
|
+
"body_length": len(resp.content),
|
|
302
|
+
"truncated": truncated,
|
|
303
|
+
"duration_ms": duration_ms,
|
|
304
|
+
# body not recorded
|
|
305
|
+
**({
|
|
306
|
+
"prompt_tokens": _usage.get("prompt_tokens", 0),
|
|
307
|
+
"completion_tokens": _usage.get("completion_tokens", 0),
|
|
308
|
+
"total_tokens": (
|
|
309
|
+
_usage.get("total_tokens")
|
|
310
|
+
or _usage.get("prompt_tokens", 0) + _usage.get("completion_tokens", 0)
|
|
311
|
+
),
|
|
312
|
+
"model": _model or "unknown",
|
|
313
|
+
} if _usage else {}),
|
|
314
|
+
}, redacted=False)
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"status_code": resp.status_code,
|
|
318
|
+
"headers": dict(resp.headers),
|
|
319
|
+
"body": body_str,
|
|
320
|
+
"json": json_data,
|
|
321
|
+
"content_type": content_type,
|
|
322
|
+
"url": str(resp.url),
|
|
323
|
+
"duration_ms": duration_ms,
|
|
324
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-http
|
|
3
|
+
Version: 0.10.0
|
|
4
|
+
Summary: CHP capability adapter — governed HTTP client with URL allowlist
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,capability-host-protocol,chp,client,http
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: chp-core>=0.7.0
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
chp_adapter_http/__init__.py,sha256=lVRUhkFKFL3nEpwe6QoBFMbo9gEjmAzmmDCkcQIseKw,626
|
|
2
|
+
chp_adapter_http/adapter.py,sha256=A-3oGns-1zPkb5MWzCPYMQZBi6j1vcdxQgPjRp8tO5A,13087
|
|
3
|
+
chp_adapter_http-0.10.0.dist-info/METADATA,sha256=qe2U0gpt6l8V7nGoDbJsY1chHPc0p74i3G9xf6l-7Ks,850
|
|
4
|
+
chp_adapter_http-0.10.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
chp_adapter_http-0.10.0.dist-info/entry_points.txt,sha256=ag3d2XLJSLuewFyLdelQ0yC9Mw87m-zVjnuwSrMIcGw,51
|
|
6
|
+
chp_adapter_http-0.10.0.dist-info/RECORD,,
|