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.
@@ -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