webagent-protocol 0.2.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,137 @@
1
+ """Exceptions raised by the WAP consumer SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class WAPError(Exception):
9
+ """Base class for every error raised by :mod:`wap.client`."""
10
+
11
+
12
+ class ManifestNotFound(WAPError):
13
+ """No valid ``/.well-known/wap.json`` (or legacy ``agent.json``) could be retrieved for a domain."""
14
+
15
+ def __init__(self, domain: str, message: str, *, url: str | None = None, status_code: int | None = None) -> None:
16
+ super().__init__(f"{domain}: {message}")
17
+ self.domain = domain
18
+ self.url = url
19
+ self.status_code = status_code
20
+
21
+
22
+ class VerificationFailed(WAPError):
23
+ """A manifest or message failed cryptographic or binding verification."""
24
+
25
+ def __init__(self, domain: str, message: str) -> None:
26
+ super().__init__(f"{domain}: {message}")
27
+ self.domain = domain
28
+
29
+
30
+ class InsecureTransport(WAPError):
31
+ """Plain HTTP was requested for a non-loopback domain without ``allow_insecure``."""
32
+
33
+
34
+ class CapabilityNotFound(WAPError):
35
+ """The requested capability is not declared in the remote manifest."""
36
+
37
+ def __init__(self, domain: str, capability_id: str, available: list[str]) -> None:
38
+ super().__init__(
39
+ f"{domain} does not offer capability {capability_id!r}; available: {', '.join(available) or 'none'}"
40
+ )
41
+ self.domain = domain
42
+ self.capability_id = capability_id
43
+ self.available = available
44
+
45
+
46
+ class SchemaValidationError(WAPError):
47
+ """``payload`` does not satisfy the capability's declared ``input_schema``."""
48
+
49
+ def __init__(self, capability_id: str, errors: list[str]) -> None:
50
+ super().__init__(f"payload for {capability_id!r} is invalid: {'; '.join(errors)}")
51
+ self.capability_id = capability_id
52
+ self.errors = errors
53
+
54
+
55
+ class ProtocolError(WAPError):
56
+ """The remote agent answered with a WAP error object or violated the protocol."""
57
+
58
+ def __init__(
59
+ self,
60
+ code: str,
61
+ message: str,
62
+ *,
63
+ status_code: int | None = None,
64
+ details: dict[str, Any] | None = None,
65
+ retry_after: float | None = None,
66
+ ) -> None:
67
+ super().__init__(f"[{code}] {message}")
68
+ self.code = code
69
+ self.message = message
70
+ self.status_code = status_code
71
+ self.details = details or {}
72
+ self.retry_after = retry_after
73
+
74
+
75
+ class RateLimited(ProtocolError):
76
+ """HTTP 429: back off for ``retry_after`` seconds."""
77
+
78
+
79
+ class ProofOfWorkFailed(ProtocolError):
80
+ """The server kept rejecting proof-of-work solutions."""
81
+
82
+
83
+ class AuthRequired(ProtocolError):
84
+ """The capability requires a bearer token that was missing or rejected."""
85
+
86
+
87
+ class EffectsNotPermitted(ProtocolError):
88
+ """The business wanted to run a capability with stronger effects than the request permitted.
89
+
90
+ ``details`` names the ``capability_id``, its ``effects`` and the ``payload`` it would have
91
+ used, so the user can be asked and the capability then called directly.
92
+ """
93
+
94
+
95
+ class ConfirmationDeclined(WAPError):
96
+ """The client's ``confirm`` hook declined an action with side effects; nothing was sent."""
97
+
98
+ def __init__(self, domain: str, capability_id: str) -> None:
99
+ super().__init__(f"{domain}: the user declined {capability_id!r}; nothing was sent")
100
+ self.domain = domain
101
+ self.capability_id = capability_id
102
+
103
+
104
+ class ConversationStopped(ProtocolError):
105
+ """The conversation was halted to prevent an agent-to-agent loop.
106
+
107
+ Raised either locally (``status_code is None``: the client refused to send) or
108
+ because the business agent refused. Do not retry the same request; change it
109
+ or hand control back to the user.
110
+ """
111
+
112
+
113
+ class LoopDetected(ConversationStopped):
114
+ """The same exchange (or a short cycle of exchanges) keeps repeating with no new information."""
115
+
116
+
117
+ class ConversationLimitReached(ConversationStopped):
118
+ """The session used up its turn budget."""
119
+
120
+
121
+ __all__ = [
122
+ "AuthRequired",
123
+ "CapabilityNotFound",
124
+ "ConfirmationDeclined",
125
+ "ConversationLimitReached",
126
+ "ConversationStopped",
127
+ "EffectsNotPermitted",
128
+ "InsecureTransport",
129
+ "LoopDetected",
130
+ "ManifestNotFound",
131
+ "ProofOfWorkFailed",
132
+ "ProtocolError",
133
+ "RateLimited",
134
+ "SchemaValidationError",
135
+ "VerificationFailed",
136
+ "WAPError",
137
+ ]
wap/client/resolver.py ADDED
@@ -0,0 +1,361 @@
1
+ """Well-known manifest resolution, verification and caching.
2
+
3
+ Resolution algorithm (``docs/spec_rfc.md`` Section 5):
4
+
5
+ 1. Normalise the target into an authority and a base URL. HTTPS is mandatory
6
+ except for loopback authorities (``localhost``, ``127.0.0.0/8``) or when the
7
+ caller explicitly opts into ``allow_insecure``.
8
+ 2. Optionally resolve the host through DNS and refuse private, loopback or
9
+ link-local addresses (SSRF protection for LLM-driven callers).
10
+ 3. ``GET {base}/.well-known/agent.json`` without following redirects. On 404 or
11
+ a connection failure for an apex domain, fall back once to ``www.{domain}``.
12
+ 4. Validate the document against :class:`AgentManifest`, verify the embedded
13
+ Ed25519 signature, verify ``X-WAP-Signature`` over the raw body when
14
+ present, enforce domain binding, pinned keys and expiry.
15
+ 5. Cache the verified manifest until ``min(cache_ttl, expires_at)``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import ipaddress
22
+ import json
23
+ import socket
24
+ import time
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from typing import Literal
28
+ from urllib.parse import urlsplit
29
+
30
+ import httpx
31
+ from pydantic import ValidationError
32
+
33
+ from ..spec.crypto import fingerprint, verify_bytes, verify_endorsement, verify_model
34
+ from ..spec.models import (
35
+ HEADER_SIGNATURE,
36
+ WAP_VERSION,
37
+ WELL_KNOWN_PATH,
38
+ WELL_KNOWN_PATHS,
39
+ AgentManifest,
40
+ authority_host,
41
+ is_local_authority,
42
+ normalize_authority,
43
+ )
44
+ from .dnskey import TxtResolver, check_dns_key
45
+ from .exceptions import InsecureTransport, ManifestNotFound, VerificationFailed
46
+
47
+ MAX_MANIFEST_BYTES = 1024 * 1024
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Target:
52
+ authority: str
53
+ scheme: str
54
+
55
+ @property
56
+ def base_url(self) -> str:
57
+ return f"{self.scheme}://{self.authority}"
58
+
59
+ @property
60
+ def manifest_url(self) -> str:
61
+ return self.base_url + WELL_KNOWN_PATH
62
+
63
+ def url_for(self, path: str) -> str:
64
+ return self.base_url + path
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ResolvedManifest:
69
+ manifest: AgentManifest
70
+ url: str
71
+ fetched_at: float
72
+ cache_expires_at: float
73
+ header_signature_verified: bool
74
+
75
+ @property
76
+ def fingerprint(self) -> str:
77
+ return fingerprint(self.manifest.public_key)
78
+
79
+
80
+ def parse_target(target: str, *, allow_insecure: bool = False) -> Target:
81
+ """Turn ``bakery.example``, ``localhost:8000`` or ``https://bakery.example/x`` into a :class:`Target`."""
82
+ raw = target.strip()
83
+ if "://" in raw:
84
+ parts = urlsplit(raw)
85
+ scheme = parts.scheme.lower()
86
+ if scheme not in ("http", "https"):
87
+ raise ValueError(f"unsupported URL scheme {parts.scheme!r}")
88
+ if parts.username or parts.password:
89
+ raise ValueError("URLs with credentials are not allowed")
90
+ authority = normalize_authority(parts.netloc)
91
+ else:
92
+ authority = normalize_authority(raw.split("/", 1)[0])
93
+ scheme = "http" if is_local_authority(authority) else "https"
94
+ if scheme == "http" and not (is_local_authority(authority) or allow_insecure):
95
+ raise InsecureTransport(
96
+ f"refusing plain HTTP for non-loopback domain {authority!r}; pass allow_insecure=True to override"
97
+ )
98
+ return Target(authority=authority, scheme=scheme)
99
+
100
+
101
+ def _host_is_within(host: str, domain_host: str) -> bool:
102
+ return host == domain_host or host.endswith("." + domain_host)
103
+
104
+
105
+ def _is_ip_literal(host: str) -> bool:
106
+ try:
107
+ ipaddress.ip_address(host)
108
+ except ValueError:
109
+ return False
110
+ return True
111
+
112
+
113
+ def _is_public_address(address: str) -> bool:
114
+ ip = ipaddress.ip_address(address)
115
+ return not (
116
+ ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified
117
+ )
118
+
119
+
120
+ class ManifestResolver:
121
+ """Fetches, verifies and caches :class:`AgentManifest` documents."""
122
+
123
+ def __init__(
124
+ self,
125
+ http: httpx.AsyncClient,
126
+ *,
127
+ cache_ttl: float = 300.0,
128
+ pinned_keys: dict[str, str] | None = None,
129
+ allow_insecure: bool = False,
130
+ verify_dns: bool = False,
131
+ block_private_networks: bool = False,
132
+ allow_loopback: bool = False,
133
+ www_fallback: bool = True,
134
+ trust_on_first_use: bool = False,
135
+ on_key_change: Callable[[str, str, str], None] | None = None,
136
+ dns_key_policy: Literal["off", "if-present", "require"] = "off",
137
+ txt_resolver: TxtResolver | None = None,
138
+ ) -> None:
139
+ self.http = http
140
+ self.cache_ttl = cache_ttl
141
+ self.pinned_keys = {normalize_authority(k): v.lower() for k, v in (pinned_keys or {}).items()}
142
+ self.allow_insecure = allow_insecure
143
+ self.verify_dns = verify_dns or block_private_networks
144
+ self.block_private_networks = block_private_networks
145
+ # With block_private_networks, still allow 127.0.0.0/8 / ::1 (local development).
146
+ self.allow_loopback = allow_loopback
147
+ self.www_fallback = www_fallback
148
+ # With trust_on_first_use, the first verified key for a domain is pinned automatically.
149
+ self.trust_on_first_use = trust_on_first_use
150
+ # Called as on_key_change(domain, old_key, new_key) when a pinned key rotates (endorsed).
151
+ self.on_key_change = on_key_change
152
+ self.dns_key_policy = dns_key_policy
153
+ self.txt_resolver = txt_resolver
154
+ self._cache: dict[str, ResolvedManifest] = {}
155
+ self._locks: dict[str, asyncio.Lock] = {}
156
+
157
+ # ------------------------------------------------------------------ public API
158
+
159
+ async def resolve(self, target: str, *, force_refresh: bool = False) -> AgentManifest:
160
+ return (await self.resolve_detailed(target, force_refresh=force_refresh)).manifest
161
+
162
+ async def resolve_detailed(self, target: str, *, force_refresh: bool = False) -> ResolvedManifest:
163
+ parsed = parse_target(target, allow_insecure=self.allow_insecure)
164
+ key = parsed.authority
165
+ cached = self._cache.get(key)
166
+ if cached is not None and not force_refresh and time.time() < cached.cache_expires_at:
167
+ return cached
168
+ lock = self._locks.setdefault(key, asyncio.Lock())
169
+ async with lock:
170
+ cached = self._cache.get(key)
171
+ if cached is not None and not force_refresh and time.time() < cached.cache_expires_at:
172
+ return cached
173
+ resolved = await self._fetch_with_fallback(parsed)
174
+ self._cache[key] = resolved
175
+ return resolved
176
+
177
+ def cached(self, target: str) -> ResolvedManifest | None:
178
+ parsed = parse_target(target, allow_insecure=True)
179
+ entry = self._cache.get(parsed.authority)
180
+ if entry is None or time.time() >= entry.cache_expires_at:
181
+ return None
182
+ return entry
183
+
184
+ def invalidate(self, target: str | None = None) -> None:
185
+ if target is None:
186
+ self._cache.clear()
187
+ else:
188
+ self._cache.pop(parse_target(target, allow_insecure=True).authority, None)
189
+
190
+ def pin(self, domain: str, public_key: str) -> None:
191
+ self.pinned_keys[normalize_authority(domain)] = public_key.lower()
192
+
193
+ # ------------------------------------------------------------------ internals
194
+
195
+ async def _check_dns(self, target: Target) -> None:
196
+ host = authority_host(target.authority)
197
+ if _is_ip_literal(host):
198
+ addresses = [host]
199
+ else:
200
+ port = int(target.authority.rsplit(":", 1)[1]) if ":" in target.authority else 443
201
+ loop = asyncio.get_running_loop()
202
+ try:
203
+ infos = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
204
+ except socket.gaierror as exc:
205
+ raise ManifestNotFound(target.authority, f"DNS resolution failed: {exc}") from exc
206
+ addresses = sorted({str(info[4][0]) for info in infos})
207
+ if not addresses:
208
+ raise ManifestNotFound(target.authority, "DNS returned no addresses")
209
+ if self.block_private_networks:
210
+ blocked = [
211
+ a
212
+ for a in addresses
213
+ if not _is_public_address(a) and not (self.allow_loopback and ipaddress.ip_address(a).is_loopback)
214
+ ]
215
+ if blocked:
216
+ raise VerificationFailed(
217
+ target.authority, f"resolves to non-public address(es) {blocked}; blocked by policy"
218
+ )
219
+
220
+ async def _fetch_paths(self, target: Target, *, expected: str) -> ResolvedManifest:
221
+ """Try ``/.well-known/wap.json``, then the legacy ``/.well-known/agent.json``."""
222
+ first_error: ManifestNotFound | None = None
223
+ for path in WELL_KNOWN_PATHS:
224
+ try:
225
+ return await self._fetch(target, expected=expected, path=path)
226
+ except ManifestNotFound as exc:
227
+ # Only fall back when the primary path is simply absent.
228
+ if exc.status_code != 404:
229
+ raise
230
+ first_error = first_error or exc
231
+ assert first_error is not None
232
+ raise first_error
233
+
234
+ async def _fetch_with_fallback(self, target: Target) -> ResolvedManifest:
235
+ try:
236
+ return await self._fetch_paths(target, expected=target.authority)
237
+ except ManifestNotFound as first_error:
238
+ host = authority_host(target.authority)
239
+ is_ip = _is_ip_literal(host)
240
+ if not self.www_fallback or host.startswith("www.") or is_local_authority(target.authority) or is_ip:
241
+ raise
242
+ alternate = Target(authority="www." + target.authority, scheme=target.scheme)
243
+ try:
244
+ return await self._fetch_paths(alternate, expected=alternate.authority)
245
+ except ManifestNotFound:
246
+ raise first_error from None
247
+
248
+ async def _fetch(self, target: Target, *, expected: str, path: str = WELL_KNOWN_PATH) -> ResolvedManifest:
249
+ if self.verify_dns:
250
+ await self._check_dns(target)
251
+ url = target.url_for(path)
252
+ try:
253
+ async with self.http.stream(
254
+ "GET", url, headers={"Accept": "application/json", "X-WAP-Version": WAP_VERSION}, follow_redirects=False
255
+ ) as response:
256
+ if response.status_code in (301, 302, 303, 307, 308):
257
+ raise ManifestNotFound(
258
+ target.authority,
259
+ f"manifest endpoint redirected to {response.headers.get('location')!r}; "
260
+ "redirects are not followed",
261
+ url=url,
262
+ status_code=response.status_code,
263
+ )
264
+ if response.status_code != 200:
265
+ raise ManifestNotFound(
266
+ target.authority,
267
+ f"HTTP {response.status_code} from {url}",
268
+ url=url,
269
+ status_code=response.status_code,
270
+ )
271
+ body = bytearray()
272
+ async for piece in response.aiter_bytes():
273
+ body.extend(piece)
274
+ if len(body) > MAX_MANIFEST_BYTES:
275
+ raise ManifestNotFound(
276
+ target.authority, f"manifest exceeds {MAX_MANIFEST_BYTES} bytes", url=url
277
+ )
278
+ header_signature = response.headers.get(HEADER_SIGNATURE)
279
+ except httpx.HTTPError as exc:
280
+ raise ManifestNotFound(target.authority, f"could not fetch {url}: {exc!s}", url=url) from exc
281
+
282
+ try:
283
+ document = json.loads(bytes(body))
284
+ except ValueError as exc:
285
+ raise ManifestNotFound(target.authority, "manifest is not valid JSON", url=url) from exc
286
+ if not isinstance(document, dict) or "wap_version" not in document:
287
+ raise ManifestNotFound(
288
+ target.authority,
289
+ f"{url} is not a WAP manifest (no wap_version); it may be another agent-description format",
290
+ url=url,
291
+ )
292
+ try:
293
+ manifest = AgentManifest.model_validate(document)
294
+ except ValidationError as exc:
295
+ raise VerificationFailed(target.authority, f"manifest failed schema validation: {exc}") from exc
296
+
297
+ self._verify(manifest, expected=expected)
298
+ if self.dns_key_policy != "off":
299
+ await check_dns_key(manifest, required=self.dns_key_policy == "require", txt_resolver=self.txt_resolver)
300
+ header_ok = False
301
+ if header_signature:
302
+ if not verify_bytes(manifest.public_key, bytes(body), header_signature):
303
+ raise VerificationFailed(target.authority, "X-WAP-Signature header does not match the response body")
304
+ header_ok = True
305
+
306
+ now = time.time()
307
+ ttl = self.cache_ttl
308
+ if manifest.expires_at is not None:
309
+ ttl = min(ttl, manifest.expires_at - now)
310
+ return ResolvedManifest(
311
+ manifest=manifest,
312
+ url=url,
313
+ fetched_at=now,
314
+ cache_expires_at=now + max(0.0, ttl),
315
+ header_signature_verified=header_ok,
316
+ )
317
+
318
+ def _verify(self, manifest: AgentManifest, *, expected: str) -> None:
319
+ if manifest.wap_version != WAP_VERSION:
320
+ raise VerificationFailed(expected, f"unsupported wap_version {manifest.wap_version!r}")
321
+ if manifest.domain != expected:
322
+ raise VerificationFailed(
323
+ expected, f"manifest is bound to {manifest.domain!r}, not the requested domain {expected!r}"
324
+ )
325
+ if not verify_model(manifest, manifest.public_key):
326
+ raise VerificationFailed(expected, "manifest signature is missing or invalid")
327
+ if manifest.is_expired():
328
+ raise VerificationFailed(expected, "manifest has expired")
329
+ domain_host = authority_host(manifest.domain)
330
+ for label, url in (
331
+ ("interaction_url", manifest.interaction_url),
332
+ ("challenge_url", manifest.challenge_url),
333
+ ("mcp_url", manifest.mcp_url),
334
+ ):
335
+ if url is None:
336
+ continue
337
+ parts = urlsplit(url)
338
+ host = (parts.hostname or "").lower()
339
+ if not _host_is_within(host, domain_host):
340
+ raise VerificationFailed(
341
+ expected, f"{label} host {host!r} is outside the manifest domain {domain_host!r}"
342
+ )
343
+ if parts.scheme == "http" and not (is_local_authority(parts.netloc) or self.allow_insecure):
344
+ raise VerificationFailed(expected, f"{label} uses plain HTTP")
345
+ pinned = self.pinned_keys.get(manifest.domain)
346
+ if pinned is not None and pinned != manifest.public_key:
347
+ if not verify_endorsement(manifest, pinned):
348
+ raise VerificationFailed(
349
+ expected,
350
+ f"public key {fingerprint(manifest.public_key)} does not match pinned key {fingerprint(pinned)} "
351
+ "and the pinned key did not endorse it",
352
+ )
353
+ # Rotation endorsed by the key we trusted: follow it.
354
+ self.pinned_keys[manifest.domain] = manifest.public_key
355
+ if self.on_key_change is not None:
356
+ self.on_key_change(manifest.domain, pinned, manifest.public_key)
357
+ elif pinned is None and self.trust_on_first_use:
358
+ self.pinned_keys[manifest.domain] = manifest.public_key
359
+
360
+
361
+ __all__ = ["ManifestResolver", "ResolvedManifest", "Target", "parse_target"]