chp-adapter-http 0.11.0__tar.gz
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.
- chp_adapter_http-0.11.0/.gitignore +36 -0
- chp_adapter_http-0.11.0/PKG-INFO +21 -0
- chp_adapter_http-0.11.0/README.md +0 -0
- chp_adapter_http-0.11.0/chp_adapter_http/__init__.py +23 -0
- chp_adapter_http-0.11.0/chp_adapter_http/adapter.py +324 -0
- chp_adapter_http-0.11.0/pyproject.toml +41 -0
- chp_adapter_http-0.11.0/tests/test_http_adapter.py +394 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
out/
|
|
5
|
+
.turbo/
|
|
6
|
+
*.tsbuildinfo
|
|
7
|
+
.yalc/
|
|
8
|
+
yalc.lock
|
|
9
|
+
*.tgz
|
|
10
|
+
.env
|
|
11
|
+
.env.*
|
|
12
|
+
coverage/
|
|
13
|
+
dashboard/
|
|
14
|
+
components/
|
|
15
|
+
# ...but the cockpit's app source lives in app/components — never ignore real source.
|
|
16
|
+
!cockpit/app/components/
|
|
17
|
+
!cockpit/app/components/**
|
|
18
|
+
!chp-evidence/app/components/
|
|
19
|
+
!chp-evidence/app/components/**
|
|
20
|
+
.tech-hub-cache/
|
|
21
|
+
__pycache__/
|
|
22
|
+
*.py[cod]
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.chp/
|
|
25
|
+
.DS_Store
|
|
26
|
+
.coverage
|
|
27
|
+
.forge/
|
|
28
|
+
.hypothesis/
|
|
29
|
+
.claude/
|
|
30
|
+
|
|
31
|
+
# chp-agent evidence store
|
|
32
|
+
.chp-agent/sessions.sqlite
|
|
33
|
+
.publish-dist/
|
|
34
|
+
|
|
35
|
+
# matrix-bot runtime session registry (CC session ids)
|
|
36
|
+
matrix-bot/sessions.json
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-http
|
|
3
|
+
Version: 0.11.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'
|
|
File without changes
|
|
@@ -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,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chp-adapter-http"
|
|
7
|
+
version = "0.11.0"
|
|
8
|
+
description = "CHP capability adapter — governed HTTP client with URL allowlist"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Auxo" }]
|
|
13
|
+
keywords = ["chp", "capability-host-protocol", "http", "client", "adapter"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"chp-core>=0.7.0",
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.entry-points."chp.adapters"]
|
|
31
|
+
http = "chp_adapter_http:HttpAdapter"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = ["pytest>=8.0"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
pythonpath = ["."]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["chp_adapter_http"]
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Tests for chp_adapter_http.adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
11
|
+
from chp_core.store import SQLiteEvidenceStore
|
|
12
|
+
|
|
13
|
+
from chp_adapter_http import HttpAdapter, HttpConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# --------------------------------------------------------------------------
|
|
17
|
+
# Helpers
|
|
18
|
+
# --------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def _make_transport(body: str = '{"ok": true}', status: int = 200,
|
|
21
|
+
content_type: str = "application/json"):
|
|
22
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
23
|
+
return httpx.Response(status, content=body.encode(),
|
|
24
|
+
headers={"content-type": content_type})
|
|
25
|
+
return httpx.MockTransport(handler)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _capturing_transport(capture: dict, body: str = '{"ok": true}', status: int = 200):
|
|
29
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
30
|
+
capture["method"] = req.method
|
|
31
|
+
capture["url"] = str(req.url)
|
|
32
|
+
capture["headers"] = dict(req.headers)
|
|
33
|
+
capture["body"] = req.content
|
|
34
|
+
return httpx.Response(status, json={"ok": True})
|
|
35
|
+
return httpx.MockTransport(handler)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_host(config=None):
|
|
39
|
+
host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
|
|
40
|
+
register_adapter(host, HttpAdapter(config))
|
|
41
|
+
return host
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cap_events(store):
|
|
45
|
+
return [e for e in store.all() if "capability_uri" not in e["payload"]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------
|
|
49
|
+
# 1. Shaping
|
|
50
|
+
# --------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
class TestShaping:
|
|
53
|
+
def test_one_capability(self):
|
|
54
|
+
ids = {c.descriptor.id for c in HttpAdapter().capabilities()}
|
|
55
|
+
assert ids == {"chp.adapters.http.request"}
|
|
56
|
+
|
|
57
|
+
def test_medium_risk(self):
|
|
58
|
+
caps = {c.descriptor.id: c.descriptor for c in HttpAdapter().capabilities()}
|
|
59
|
+
assert caps["chp.adapters.http.request"].risk == "medium"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------
|
|
63
|
+
# 2. Success path
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class TestSuccess:
|
|
67
|
+
def test_get_returns_status_and_body(self):
|
|
68
|
+
host = _make_host(HttpConfig(transport=_make_transport('{"hello": "world"}')))
|
|
69
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
70
|
+
"method": "GET", "url": "https://api.example.com/data"
|
|
71
|
+
})
|
|
72
|
+
assert r.outcome == "success"
|
|
73
|
+
assert r.data["status_code"] == 200
|
|
74
|
+
assert r.data["json"] == {"hello": "world"}
|
|
75
|
+
assert r.data["body"] == '{"hello": "world"}'
|
|
76
|
+
|
|
77
|
+
def test_post_with_json_body(self):
|
|
78
|
+
capture: dict = {}
|
|
79
|
+
host = _make_host(HttpConfig(transport=_capturing_transport(capture)))
|
|
80
|
+
host.invoke("chp.adapters.http.request", {
|
|
81
|
+
"method": "POST",
|
|
82
|
+
"url": "https://api.example.com/items",
|
|
83
|
+
"json_body": {"name": "widget"},
|
|
84
|
+
})
|
|
85
|
+
assert capture["method"] == "POST"
|
|
86
|
+
assert json.loads(capture["body"]) == {"name": "widget"}
|
|
87
|
+
|
|
88
|
+
def test_query_params_sent(self):
|
|
89
|
+
capture: dict = {}
|
|
90
|
+
host = _make_host(HttpConfig(transport=_capturing_transport(capture)))
|
|
91
|
+
host.invoke("chp.adapters.http.request", {
|
|
92
|
+
"method": "GET",
|
|
93
|
+
"url": "https://api.example.com/search",
|
|
94
|
+
"params": {"q": "hello", "limit": "10"},
|
|
95
|
+
})
|
|
96
|
+
assert "q=hello" in capture["url"]
|
|
97
|
+
|
|
98
|
+
def test_custom_headers_sent(self):
|
|
99
|
+
capture: dict = {}
|
|
100
|
+
host = _make_host(HttpConfig(transport=_capturing_transport(capture)))
|
|
101
|
+
host.invoke("chp.adapters.http.request", {
|
|
102
|
+
"method": "GET",
|
|
103
|
+
"url": "https://api.example.com/",
|
|
104
|
+
"headers": {"X-Custom": "value"},
|
|
105
|
+
})
|
|
106
|
+
assert capture["headers"].get("x-custom") == "value"
|
|
107
|
+
|
|
108
|
+
def test_default_headers_merged(self):
|
|
109
|
+
capture: dict = {}
|
|
110
|
+
host = _make_host(HttpConfig(
|
|
111
|
+
default_headers={"X-App-Id": "app123"},
|
|
112
|
+
transport=_capturing_transport(capture),
|
|
113
|
+
))
|
|
114
|
+
host.invoke("chp.adapters.http.request", {
|
|
115
|
+
"method": "GET", "url": "https://api.example.com/"
|
|
116
|
+
})
|
|
117
|
+
assert capture["headers"].get("x-app-id") == "app123"
|
|
118
|
+
|
|
119
|
+
def test_non_json_body_returned(self):
|
|
120
|
+
host = _make_host(HttpConfig(
|
|
121
|
+
transport=_make_transport("plain text response", content_type="text/plain")
|
|
122
|
+
))
|
|
123
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
124
|
+
"method": "GET", "url": "https://example.com/text"
|
|
125
|
+
})
|
|
126
|
+
assert r.data["body"] == "plain text response"
|
|
127
|
+
assert r.data["json"] is None
|
|
128
|
+
|
|
129
|
+
def test_4xx_response_succeeds_with_status(self):
|
|
130
|
+
host = _make_host(HttpConfig(transport=_make_transport('{"error":"not found"}', status=404)))
|
|
131
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
132
|
+
"method": "GET", "url": "https://api.example.com/missing"
|
|
133
|
+
})
|
|
134
|
+
assert r.outcome == "success"
|
|
135
|
+
assert r.data["status_code"] == 404
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------
|
|
139
|
+
# 3. URL allowlist
|
|
140
|
+
# --------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
class TestAllowlist:
|
|
143
|
+
def test_url_outside_allowed_fails(self):
|
|
144
|
+
host = _make_host(HttpConfig(
|
|
145
|
+
allowed_origins=["https://api.allowed.com"],
|
|
146
|
+
transport=_make_transport(),
|
|
147
|
+
))
|
|
148
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
149
|
+
"method": "GET", "url": "https://evil.com/steal"
|
|
150
|
+
})
|
|
151
|
+
assert r.outcome == "failure"
|
|
152
|
+
|
|
153
|
+
def test_url_inside_allowed_succeeds(self):
|
|
154
|
+
host = _make_host(HttpConfig(
|
|
155
|
+
allowed_origins=["https://api.allowed.com"],
|
|
156
|
+
transport=_make_transport(),
|
|
157
|
+
))
|
|
158
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
159
|
+
"method": "GET", "url": "https://api.allowed.com/v1/data"
|
|
160
|
+
})
|
|
161
|
+
assert r.outcome == "success"
|
|
162
|
+
|
|
163
|
+
def test_no_allowlist_permits_all(self):
|
|
164
|
+
host = _make_host(HttpConfig(
|
|
165
|
+
allowed_origins=None,
|
|
166
|
+
transport=_make_transport(),
|
|
167
|
+
))
|
|
168
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
169
|
+
"method": "GET", "url": "https://anywhere.example.com/path"
|
|
170
|
+
})
|
|
171
|
+
assert r.outcome == "success"
|
|
172
|
+
|
|
173
|
+
def test_metadata_endpoint_blocked_even_without_allowlist(self):
|
|
174
|
+
# SSRF crown jewel — denied by default, no allowlist required.
|
|
175
|
+
host = _make_host(HttpConfig(allowed_origins=None, transport=_make_transport()))
|
|
176
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
177
|
+
"method": "GET", "url": "http://169.254.169.254/latest/meta-data/iam/",
|
|
178
|
+
})
|
|
179
|
+
assert r.outcome == "failure"
|
|
180
|
+
|
|
181
|
+
def test_prefix_confusion_origin_not_admitted(self):
|
|
182
|
+
# 'https://api.allowed.com' must NOT admit a look-alike sibling host.
|
|
183
|
+
host = _make_host(HttpConfig(
|
|
184
|
+
allowed_origins=["https://api.allowed.com"],
|
|
185
|
+
transport=_make_transport(),
|
|
186
|
+
))
|
|
187
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
188
|
+
"method": "GET", "url": "https://api.allowed.com.attacker.net/x",
|
|
189
|
+
})
|
|
190
|
+
assert r.outcome == "failure"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------
|
|
194
|
+
# 4. Schema validation
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
class TestSchema:
|
|
198
|
+
def test_missing_method_denied(self):
|
|
199
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
200
|
+
r = host.invoke("chp.adapters.http.request", {"url": "https://x.com"})
|
|
201
|
+
assert r.outcome == "denied"
|
|
202
|
+
|
|
203
|
+
def test_missing_url_denied(self):
|
|
204
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
205
|
+
r = host.invoke("chp.adapters.http.request", {"method": "GET"})
|
|
206
|
+
assert r.outcome == "denied"
|
|
207
|
+
|
|
208
|
+
def test_extra_field_denied(self):
|
|
209
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
210
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
211
|
+
"method": "GET", "url": "https://x.com", "injected": "bad"
|
|
212
|
+
})
|
|
213
|
+
assert r.outcome == "denied"
|
|
214
|
+
|
|
215
|
+
def test_invalid_method_denied(self):
|
|
216
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
217
|
+
r = host.invoke("chp.adapters.http.request", {
|
|
218
|
+
"method": "HACK", "url": "https://x.com"
|
|
219
|
+
})
|
|
220
|
+
assert r.outcome == "denied"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
# 5. Evidence hygiene
|
|
225
|
+
# --------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
class TestEvidenceHygiene:
|
|
228
|
+
def test_response_body_not_in_evidence(self):
|
|
229
|
+
host = _make_host(HttpConfig(transport=_make_transport('SECRET_RESPONSE_BODY_XYZ')))
|
|
230
|
+
host.invoke("chp.adapters.http.request", {
|
|
231
|
+
"method": "GET", "url": "https://api.example.com/"
|
|
232
|
+
})
|
|
233
|
+
dump = str([e["payload"] for e in _cap_events(host.store)])
|
|
234
|
+
assert "SECRET_RESPONSE_BODY_XYZ" not in dump
|
|
235
|
+
|
|
236
|
+
def test_request_header_values_not_in_evidence(self):
|
|
237
|
+
capture: dict = {}
|
|
238
|
+
host = _make_host(HttpConfig(transport=_capturing_transport(capture)))
|
|
239
|
+
host.invoke("chp.adapters.http.request", {
|
|
240
|
+
"method": "GET",
|
|
241
|
+
"url": "https://api.example.com/",
|
|
242
|
+
"headers": {"Authorization": "Bearer SECRET_TOKEN_VALUE"},
|
|
243
|
+
})
|
|
244
|
+
dump = str([e["payload"] for e in _cap_events(host.store)])
|
|
245
|
+
assert "SECRET_TOKEN_VALUE" not in dump
|
|
246
|
+
|
|
247
|
+
def test_request_header_keys_in_evidence(self):
|
|
248
|
+
capture: dict = {}
|
|
249
|
+
host = _make_host(HttpConfig(transport=_capturing_transport(capture)))
|
|
250
|
+
host.invoke("chp.adapters.http.request", {
|
|
251
|
+
"method": "GET",
|
|
252
|
+
"url": "https://api.example.com/",
|
|
253
|
+
"headers": {"Authorization": "Bearer tok"},
|
|
254
|
+
})
|
|
255
|
+
dump = str([e["payload"] for e in _cap_events(host.store)])
|
|
256
|
+
assert "Authorization" in dump or "authorization" in dump
|
|
257
|
+
|
|
258
|
+
def test_http_request_event_emitted(self):
|
|
259
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
260
|
+
host.invoke("chp.adapters.http.request", {
|
|
261
|
+
"method": "GET", "url": "https://api.example.com/"
|
|
262
|
+
})
|
|
263
|
+
types = [e["event_type"] for e in _cap_events(host.store)]
|
|
264
|
+
assert "http_request" in types
|
|
265
|
+
|
|
266
|
+
def test_http_response_event_emitted(self):
|
|
267
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
268
|
+
host.invoke("chp.adapters.http.request", {
|
|
269
|
+
"method": "GET", "url": "https://api.example.com/"
|
|
270
|
+
})
|
|
271
|
+
types = [e["event_type"] for e in _cap_events(host.store)]
|
|
272
|
+
assert "http_response" in types
|
|
273
|
+
|
|
274
|
+
def test_no_lifecycle_events_in_evidence(self):
|
|
275
|
+
host = _make_host(HttpConfig(transport=_make_transport()))
|
|
276
|
+
host.invoke("chp.adapters.http.request", {
|
|
277
|
+
"method": "GET", "url": "https://api.example.com/"
|
|
278
|
+
})
|
|
279
|
+
lifecycle = {"execution_started", "execution_completed", "execution_failed"}
|
|
280
|
+
types = {e["event_type"] for e in _cap_events(host.store)}
|
|
281
|
+
assert not types & lifecycle
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------------
|
|
285
|
+
# 6. Resilience: retry + circuit breaker (transport-level failures only)
|
|
286
|
+
# --------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
def _failing_then_ok_transport(fail_count: int):
|
|
289
|
+
state = {"n": 0}
|
|
290
|
+
|
|
291
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
292
|
+
state["n"] += 1
|
|
293
|
+
if state["n"] <= fail_count:
|
|
294
|
+
raise httpx.ConnectError("connection refused", request=req)
|
|
295
|
+
return httpx.Response(200, json={"ok": True})
|
|
296
|
+
|
|
297
|
+
return httpx.MockTransport(handler), state
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class TestResilience:
|
|
301
|
+
def test_retries_transport_error_then_succeeds(self):
|
|
302
|
+
transport, state = _failing_then_ok_transport(2)
|
|
303
|
+
host = _make_host(HttpConfig(transport=transport, max_retries=3, backoff_base=0.0))
|
|
304
|
+
r = host.invoke("chp.adapters.http.request", {"method": "GET", "url": "http://svc.local/x"})
|
|
305
|
+
assert r.outcome == "success"
|
|
306
|
+
assert state["n"] == 3 # 2 failures + 1 success
|
|
307
|
+
|
|
308
|
+
def test_exhausts_retries_then_fails(self):
|
|
309
|
+
transport, _ = _failing_then_ok_transport(99) # always fails
|
|
310
|
+
host = _make_host(HttpConfig(transport=transport, max_retries=1, backoff_base=0.0))
|
|
311
|
+
r = host.invoke("chp.adapters.http.request", {"method": "GET", "url": "http://svc.local/x"})
|
|
312
|
+
assert r.outcome == "failure"
|
|
313
|
+
types = [e["event_type"] for e in _cap_events(host.store)]
|
|
314
|
+
assert "http_error" in types
|
|
315
|
+
|
|
316
|
+
def test_circuit_opens_after_threshold_and_fails_fast(self):
|
|
317
|
+
transport, state = _failing_then_ok_transport(99)
|
|
318
|
+
host = _make_host(HttpConfig(
|
|
319
|
+
transport=transport, max_retries=0, backoff_base=0.0,
|
|
320
|
+
circuit_threshold=3, circuit_cooldown=60.0,
|
|
321
|
+
))
|
|
322
|
+
for _ in range(3):
|
|
323
|
+
host.invoke("chp.adapters.http.request", {"method": "GET", "url": "http://svc.local/x"})
|
|
324
|
+
attempts_before = state["n"]
|
|
325
|
+
# circuit now open → next call fails fast WITHOUT hitting the transport
|
|
326
|
+
r = host.invoke("chp.adapters.http.request", {"method": "GET", "url": "http://svc.local/x"})
|
|
327
|
+
assert r.outcome == "failure"
|
|
328
|
+
assert "circuit open" in str(r.error).lower()
|
|
329
|
+
assert state["n"] == attempts_before # transport not invoked again
|
|
330
|
+
types = [e["event_type"] for e in _cap_events(host.store)]
|
|
331
|
+
assert "http_circuit_open" in types
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# --------------------------------------------------------------------------
|
|
335
|
+
# 7. Token accounting
|
|
336
|
+
# --------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def _usage_transport(prompt: int, completion: int, model: str = "test-model"):
|
|
339
|
+
body = json.dumps({
|
|
340
|
+
"choices": [{"message": {"content": "hello"}}],
|
|
341
|
+
"model": model,
|
|
342
|
+
"usage": {"prompt_tokens": prompt, "completion_tokens": completion},
|
|
343
|
+
})
|
|
344
|
+
return _make_transport(body)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class TestTokenAccounting:
|
|
348
|
+
def test_token_fields_emitted_when_usage_present(self):
|
|
349
|
+
host = _make_host(HttpConfig(transport=_usage_transport(10, 5)))
|
|
350
|
+
host.invoke("chp.adapters.http.request", {
|
|
351
|
+
"method": "POST", "url": "https://api.example.com/v1/chat/completions",
|
|
352
|
+
"json_body": {"model": "test-model", "messages": []},
|
|
353
|
+
})
|
|
354
|
+
http_responses = [
|
|
355
|
+
e for e in _cap_events(host.store) if e["event_type"] == "http_response"
|
|
356
|
+
]
|
|
357
|
+
assert len(http_responses) == 1
|
|
358
|
+
p = http_responses[0]["payload"]
|
|
359
|
+
assert p["prompt_tokens"] == 10
|
|
360
|
+
assert p["completion_tokens"] == 5
|
|
361
|
+
assert p["total_tokens"] == 15
|
|
362
|
+
assert p["model"] == "test-model"
|
|
363
|
+
|
|
364
|
+
def test_token_fields_absent_when_no_usage(self):
|
|
365
|
+
host = _make_host(HttpConfig(transport=_make_transport('{"result": "ok"}')))
|
|
366
|
+
host.invoke("chp.adapters.http.request", {
|
|
367
|
+
"method": "GET", "url": "https://api.example.com/health",
|
|
368
|
+
})
|
|
369
|
+
http_responses = [
|
|
370
|
+
e for e in _cap_events(host.store) if e["event_type"] == "http_response"
|
|
371
|
+
]
|
|
372
|
+
assert len(http_responses) == 1
|
|
373
|
+
p = http_responses[0]["payload"]
|
|
374
|
+
assert "prompt_tokens" not in p
|
|
375
|
+
assert "completion_tokens" not in p
|
|
376
|
+
assert "model" not in p
|
|
377
|
+
|
|
378
|
+
def test_model_extracted_from_request_body(self):
|
|
379
|
+
# Response has usage but no model field → fall back to json_body.model
|
|
380
|
+
body = json.dumps({
|
|
381
|
+
"choices": [],
|
|
382
|
+
"usage": {"prompt_tokens": 8, "completion_tokens": 2},
|
|
383
|
+
})
|
|
384
|
+
host = _make_host(HttpConfig(transport=_make_transport(body)))
|
|
385
|
+
host.invoke("chp.adapters.http.request", {
|
|
386
|
+
"method": "POST", "url": "https://api.example.com/v1/chat/completions",
|
|
387
|
+
"json_body": {"model": "req-body-model", "messages": []},
|
|
388
|
+
})
|
|
389
|
+
http_responses = [
|
|
390
|
+
e for e in _cap_events(host.store) if e["event_type"] == "http_response"
|
|
391
|
+
]
|
|
392
|
+
p = http_responses[0]["payload"]
|
|
393
|
+
assert p["model"] == "req-body-model"
|
|
394
|
+
assert p["prompt_tokens"] == 8
|