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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [chp.adapters]
2
+ http = chp_adapter_http:HttpAdapter