Skip to content

Commit 5e48c93

Browse files
authored
fix(registry): allowlist error detail metadata (#1075)
1 parent 7da7a23 commit 5e48c93

5 files changed

Lines changed: 457 additions & 8 deletions

File tree

src/adcp/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ def _resolve_version() -> str:
162162
"IdempotencyScopeError",
163163
"IdempotencyUnsupportedError",
164164
"RegistryError",
165+
"RegistryErrorDetails",
166+
"RegistryValidationIssue",
165167
),
166168
"adcp.feed_mirror": (
167169
"EventHandler",
@@ -1311,6 +1313,8 @@ def get_adcp_version() -> str:
13111313
"IdempotencyScopeError",
13121314
"IdempotencyUnsupportedError",
13131315
"RegistryError",
1316+
"RegistryErrorDetails",
1317+
"RegistryValidationIssue",
13141318
# Validation utilities
13151319
"SchemaValidationError",
13161320
"UnknownFieldPolicy",
@@ -1529,6 +1533,8 @@ def get_adcp_version() -> str:
15291533
IdempotencyScopeError,
15301534
IdempotencyUnsupportedError,
15311535
RegistryError,
1536+
RegistryErrorDetails,
1537+
RegistryValidationIssue,
15321538
)
15331539
from adcp.feed_mirror import (
15341540
EventHandler,

src/adcp/exceptions.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Any
5+
from typing import Any, TypedDict
66

77

88
class ADCPError(Exception):
@@ -185,6 +185,34 @@ def __init__(
185185
super().__init__(message, agent_id, None, suggestion)
186186

187187

188+
class RegistryValidationIssue(TypedDict, total=False):
189+
"""Bounded, machine-readable registry validation issue metadata."""
190+
191+
code: str
192+
field: str
193+
path: list[str | int]
194+
195+
196+
class RegistryErrorDetails(TypedDict, total=False):
197+
"""Safe registry error metadata suitable for logs and agent context.
198+
199+
Free-form server prose, rejected values, credentials, and unknown fields are
200+
intentionally excluded from this envelope.
201+
"""
202+
203+
code: str
204+
field: str
205+
policy_id: str
206+
existing_org_id: str
207+
members_only: bool
208+
request_id: str
209+
valid_values: list[str]
210+
validation_issues: list[RegistryValidationIssue]
211+
retryAfterMs: int | float
212+
retryAfter: int | float
213+
retry_after: int | float
214+
215+
188216
class RegistryError(ADCPError):
189217
"""Error from AdCP registry API operations (brand/property lookups)."""
190218

@@ -195,7 +223,7 @@ def __init__(
195223
*,
196224
method: str | None = None,
197225
retry_after_seconds: float | None = None,
198-
details: dict[str, Any] | None = None,
226+
details: RegistryErrorDetails | None = None,
199227
):
200228
"""Initialize registry error."""
201229
self.status_code = status_code

src/adcp/registry.py

Lines changed: 195 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import httpx
1818
from pydantic import BaseModel, ValidationError
1919

20-
from adcp.exceptions import RegistryError
20+
from adcp.exceptions import RegistryError, RegistryErrorDetails, RegistryValidationIssue
2121

2222
_T = TypeVar("_T", bound=BaseModel)
2323
from adcp.types.core import (
@@ -49,11 +49,25 @@
4949
MAX_BULK_DOMAINS = 100
5050
MAX_BULK_POLICIES = 100
5151
MAX_REGISTRY_ERROR_DETAILS_BYTES = 64 * 1024
52+
MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES = 4 * 1024
53+
MAX_REGISTRY_ERROR_TOKEN_LENGTH = 128
54+
MAX_REGISTRY_ERROR_CODE_LENGTH = 64
55+
MAX_REGISTRY_ERROR_FIELD_LENGTH = 128
56+
MAX_REGISTRY_ERROR_LIST_ITEMS = 20
57+
MAX_REGISTRY_ERROR_PATH_SEGMENTS = 8
5258
DEFAULT_MAX_REGISTRY_RESPONSE_BYTES = 256 * 1024
5359
DEFAULT_MAX_BULK_REGISTRY_RESPONSE_BYTES = 2 * 1024 * 1024
5460
MAX_RETRY_AFTER_SECONDS = 2_147_483.647
5561

5662
_COMMUNITY_MIRROR_PLATFORM_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
63+
_REGISTRY_ERROR_TOKEN_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
64+
_REGISTRY_ERROR_CODE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._:-]*$")
65+
_REGISTRY_ERROR_FIELD_RE = re.compile(r"^[A-Za-z0-9_.\[\]-]+$")
66+
_REGISTRY_SECRET_CODE_RE = re.compile(
67+
r"^(?:(?:sk|pk|rk)[._:-]|api[_-]?key[._:-]|bearer[._:-]|basic[._:-]|eyj)",
68+
re.IGNORECASE,
69+
)
70+
_LEGACY_REGISTRY_ERROR_CODES = frozenset({"cursor_expired", "unpublish_first", "url_immutable"})
5771

5872
_LARGE_REGISTRY_RESPONSE_PATHS = frozenset(
5973
{
@@ -135,8 +149,8 @@ def _retry_after_seconds(
135149
return None
136150

137151

138-
def _registry_error_details(response: httpx.Response) -> dict[str, Any] | None:
139-
"""Return a bounded JSON object from a registry error response."""
152+
def _registry_error_payload(response: httpx.Response) -> dict[str, Any] | None:
153+
"""Parse a bounded registry error object for immediate local processing."""
140154
content = response.content
141155
if isinstance(content, bytes) and len(content) > MAX_REGISTRY_ERROR_DETAILS_BYTES:
142156
return None
@@ -152,19 +166,195 @@ def _registry_error_details(response: httpx.Response) -> dict[str, Any] | None:
152166
return cast(dict[str, Any], details)
153167

154168

169+
def _safe_registry_error_string(
170+
value: Any,
171+
*,
172+
max_length: int,
173+
pattern: re.Pattern[str],
174+
) -> str | None:
175+
"""Return a bounded machine token, excluding remote free-form prose."""
176+
if not isinstance(value, str) or not 1 <= len(value) <= max_length:
177+
return None
178+
if pattern.fullmatch(value) is None:
179+
return None
180+
return value
181+
182+
183+
def _safe_registry_error_code(value: Any) -> str | None:
184+
"""Return a bounded code unless it resembles common credential material."""
185+
code = _safe_registry_error_string(
186+
value,
187+
max_length=MAX_REGISTRY_ERROR_CODE_LENGTH,
188+
pattern=_REGISTRY_ERROR_CODE_RE,
189+
)
190+
if code is None or _REGISTRY_SECRET_CODE_RE.match(code) is not None:
191+
return None
192+
return code
193+
194+
195+
def _safe_registry_validation_issue(value: Any) -> RegistryValidationIssue | None:
196+
"""Project one validation issue without rejected values or server prose."""
197+
if not isinstance(value, dict):
198+
return None
199+
issue: RegistryValidationIssue = {}
200+
code = _safe_registry_error_code(value.get("code"))
201+
if code is not None:
202+
issue["code"] = code
203+
field = _safe_registry_error_string(
204+
value.get("field"),
205+
max_length=MAX_REGISTRY_ERROR_FIELD_LENGTH,
206+
pattern=_REGISTRY_ERROR_FIELD_RE,
207+
)
208+
if field is not None:
209+
issue["field"] = field
210+
211+
raw_path = value.get("path")
212+
if isinstance(raw_path, list) and len(raw_path) <= MAX_REGISTRY_ERROR_PATH_SEGMENTS:
213+
path: list[str | int] = []
214+
for segment in raw_path:
215+
if isinstance(segment, bool):
216+
path = []
217+
break
218+
if isinstance(segment, int):
219+
if not 0 <= segment <= 1_000_000:
220+
path = []
221+
break
222+
path.append(segment)
223+
continue
224+
safe_segment = _safe_registry_error_string(
225+
segment,
226+
max_length=64,
227+
pattern=_REGISTRY_ERROR_FIELD_RE,
228+
)
229+
if safe_segment is None:
230+
path = []
231+
break
232+
path.append(safe_segment)
233+
if path:
234+
issue["path"] = path
235+
return issue or None
236+
237+
238+
def _safe_registry_retry_value(value: Any, *, scale: float) -> int | float | None:
239+
"""Normalize a recognized retry hint without retaining unbounded input."""
240+
seconds = _bounded_retry_seconds(value, scale=scale)
241+
if seconds is None:
242+
return None
243+
normalized = seconds / scale
244+
return int(normalized) if normalized.is_integer() else normalized
245+
246+
247+
def _projected_registry_error_size(details: RegistryErrorDetails) -> int:
248+
"""Return the serialized size of a projected metadata envelope."""
249+
return len(json.dumps(details, ensure_ascii=False).encode("utf-8"))
250+
251+
252+
def _registry_error_details(payload: dict[str, Any] | None) -> RegistryErrorDetails | None:
253+
"""Allowlist bounded machine metadata from an untrusted registry error."""
254+
if payload is None:
255+
return None
256+
257+
projected: RegistryErrorDetails = {}
258+
code_value = payload.get("code")
259+
if code_value is None and payload.get("error") in _LEGACY_REGISTRY_ERROR_CODES:
260+
# Some stable registry recovery discriminators predate the dedicated
261+
# code field and are returned in Error.error. Promote only documented
262+
# values; generic free-form error prose remains excluded even when it
263+
# happens to look like a machine token.
264+
code_value = payload.get("error")
265+
code = _safe_registry_error_code(code_value)
266+
if code is not None:
267+
projected["code"] = code
268+
field = _safe_registry_error_string(
269+
payload.get("field"),
270+
max_length=MAX_REGISTRY_ERROR_FIELD_LENGTH,
271+
pattern=_REGISTRY_ERROR_FIELD_RE,
272+
)
273+
if field is not None:
274+
projected["field"] = field
275+
for key in ("policy_id", "existing_org_id", "request_id"):
276+
safe_value = _safe_registry_error_string(
277+
payload.get(key),
278+
max_length=MAX_REGISTRY_ERROR_TOKEN_LENGTH,
279+
pattern=_REGISTRY_ERROR_TOKEN_RE,
280+
)
281+
if key == "policy_id" and safe_value is not None:
282+
projected["policy_id"] = safe_value
283+
elif key == "existing_org_id" and safe_value is not None:
284+
projected["existing_org_id"] = safe_value
285+
elif key == "request_id" and safe_value is not None:
286+
projected["request_id"] = safe_value
287+
288+
if isinstance(payload.get("members_only"), bool):
289+
projected["members_only"] = payload["members_only"]
290+
291+
retry_after_ms = _safe_registry_retry_value(payload.get("retryAfterMs"), scale=0.001)
292+
if retry_after_ms is not None:
293+
projected["retryAfterMs"] = retry_after_ms
294+
retry_after = _safe_registry_retry_value(payload.get("retryAfter"), scale=1.0)
295+
if retry_after is not None:
296+
projected["retryAfter"] = retry_after
297+
retry_after_snake = _safe_registry_retry_value(payload.get("retry_after"), scale=1.0)
298+
if retry_after_snake is not None:
299+
projected["retry_after"] = retry_after_snake
300+
301+
raw_valid_values = payload.get("valid_values")
302+
if isinstance(raw_valid_values, list):
303+
valid_values: list[str] = []
304+
for value in raw_valid_values[:MAX_REGISTRY_ERROR_LIST_ITEMS]:
305+
safe_value = _safe_registry_error_string(
306+
value,
307+
max_length=64,
308+
pattern=_REGISTRY_ERROR_TOKEN_RE,
309+
)
310+
if safe_value is None:
311+
continue
312+
candidate = {**projected, "valid_values": [*valid_values, safe_value]}
313+
if _projected_registry_error_size(cast(RegistryErrorDetails, candidate)) > (
314+
MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES
315+
):
316+
break
317+
valid_values.append(safe_value)
318+
if valid_values:
319+
projected["valid_values"] = valid_values
320+
321+
raw_issues = payload.get("details")
322+
if isinstance(raw_issues, list):
323+
issues: list[RegistryValidationIssue] = []
324+
for value in raw_issues[:MAX_REGISTRY_ERROR_LIST_ITEMS]:
325+
issue = _safe_registry_validation_issue(value)
326+
if issue is None:
327+
continue
328+
candidate = {**projected, "validation_issues": [*issues, issue]}
329+
if _projected_registry_error_size(cast(RegistryErrorDetails, candidate)) > (
330+
MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES
331+
):
332+
break
333+
issues.append(issue)
334+
if issues:
335+
projected["validation_issues"] = issues
336+
337+
if not projected:
338+
return None
339+
if _projected_registry_error_size(projected) > MAX_PROJECTED_REGISTRY_ERROR_DETAILS_BYTES:
340+
return None
341+
return projected
342+
343+
155344
def _registry_http_error(
156345
response: httpx.Response,
157346
*,
158347
method: str,
159348
operation: str,
160349
) -> RegistryError:
161350
"""Build a structured error without exposing an unbounded response body."""
162-
details = _registry_error_details(response)
351+
payload = _registry_error_payload(response)
352+
details = _registry_error_details(payload)
163353
return RegistryError(
164354
f"{operation} failed: HTTP {response.status_code}",
165355
status_code=response.status_code,
166356
method=method.upper(),
167-
retry_after_seconds=_retry_after_seconds(response, details),
357+
retry_after_seconds=_retry_after_seconds(response, payload),
168358
details=details,
169359
)
170360

tests/fixtures/public_api_snapshot.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,9 @@
384384
"RefreshResult",
385385
"RegistryClient",
386386
"RegistryError",
387+
"RegistryErrorDetails",
387388
"RegistrySync",
389+
"RegistryValidationIssue",
388390
"ReportPlanAdjustmentRequest",
389391
"ReportPlanAdjustmentResponse",
390392
"ReportPlanOutcomeRequest",

0 commit comments

Comments
 (0)