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.
- wap/__init__.py +75 -0
- wap/cli/__init__.py +5 -0
- wap/cli/main.py +369 -0
- wap/client/__init__.py +47 -0
- wap/client/dnskey.py +82 -0
- wap/client/exceptions.py +137 -0
- wap/client/resolver.py +361 -0
- wap/client/session.py +621 -0
- wap/mcp/__init__.py +5 -0
- wap/mcp/bridge.py +793 -0
- wap/mcp/safety.py +120 -0
- wap/py.typed +0 -0
- wap/server/__init__.py +29 -0
- wap/server/admission.py +117 -0
- wap/server/app.py +1071 -0
- wap/server/mcp_endpoint.py +305 -0
- wap/server/mcp_import.py +122 -0
- wap/server/middleware.py +111 -0
- wap/server/observability.py +102 -0
- wap/server/rate_limiter.py +90 -0
- wap/server/router.py +358 -0
- wap/server/state.py +117 -0
- wap/spec/__init__.py +75 -0
- wap/spec/conversation.py +231 -0
- wap/spec/crypto.py +216 -0
- wap/spec/jcs.py +132 -0
- wap/spec/models.py +453 -0
- wap/spec/pow.py +230 -0
- wap/storage/__init__.py +30 -0
- wap/storage/base.py +70 -0
- wap/storage/memory.py +115 -0
- wap/storage/meters.py +204 -0
- wap/storage/redis.py +202 -0
- webagent_protocol-0.2.0.dist-info/METADATA +401 -0
- webagent_protocol-0.2.0.dist-info/RECORD +38 -0
- webagent_protocol-0.2.0.dist-info/WHEEL +4 -0
- webagent_protocol-0.2.0.dist-info/entry_points.txt +3 -0
- webagent_protocol-0.2.0.dist-info/licenses/LICENSE +21 -0
wap/client/exceptions.py
ADDED
|
@@ -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"]
|