Use explicit fresh hosted capability snapshots for plan checks
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
5501b8bb56
commit
2621cf2752
6 changed files with 353 additions and 11 deletions
109
reuse_surface/plan_snapshot.py
Normal file
109
reuse_surface/plan_snapshot.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Read-only hosted discovery with explicit, bounded source acceptance."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MAX_SNAPSHOT_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
class SnapshotUnavailable(ValueError):
|
||||
"""The selected source cannot support a planning recommendation."""
|
||||
|
||||
|
||||
def load_hosted_snapshot(
|
||||
base_url: str, *, max_age_hours: float = 24
|
||||
) -> tuple[list[dict[str, Any]], str, dict[str, Any]]:
|
||||
"""Fetch the public index; never send intent, tokens or mutation requests.
|
||||
|
||||
Acceptance concerns the hub's composition and warning signals. It does not
|
||||
certify that every upstream repository has published its latest capability.
|
||||
Failure must not become a local fallback or a 'new capability' verdict.
|
||||
"""
|
||||
url = base_url.rstrip("/") + "/v1/federated"
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(base_url)
|
||||
valid_url = (parsed.scheme in {"http", "https"} and parsed.hostname
|
||||
and not parsed.username and not parsed.password
|
||||
and not parsed.query and not parsed.fragment)
|
||||
except ValueError:
|
||||
valid_url = False
|
||||
if not valid_url:
|
||||
raise SnapshotUnavailable("hub URL must be HTTP(S), without credentials, query or fragment")
|
||||
if not math.isfinite(max_age_hours) or max_age_hours <= 0:
|
||||
raise SnapshotUnavailable("maximum index age must be a positive finite number of hours")
|
||||
|
||||
request = urllib.request.Request(
|
||||
url, headers={"Accept": "application/json", "User-Agent": "reuse-surface/0.1"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
if response.status != 200:
|
||||
raise SnapshotUnavailable(f"hosted index returned HTTP {response.status}")
|
||||
if response.headers.get("X-Federation-Warnings"):
|
||||
raise SnapshotUnavailable("hosted index reports federation warnings; inspect its sources")
|
||||
raw = response.read(MAX_SNAPSHOT_BYTES + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise SnapshotUnavailable(f"hosted index returned HTTP {exc.code}") from exc
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
if isinstance(exc, SnapshotUnavailable):
|
||||
raise
|
||||
raise SnapshotUnavailable("hosted index could not be fetched; no local fallback used") from exc
|
||||
if len(raw) > MAX_SNAPSHOT_BYTES:
|
||||
raise SnapshotUnavailable("hosted index exceeds the 5 MiB response limit")
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (ValueError, UnicodeError) as exc:
|
||||
raise SnapshotUnavailable("hosted index is not valid JSON") from exc
|
||||
if not isinstance(payload, dict) or payload.get("stale") is not False:
|
||||
raise SnapshotUnavailable("hosted index is stale or its freshness signal is missing")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
composed = datetime.fromisoformat(payload["composed_at"])
|
||||
updated = payload["updated"]
|
||||
date.fromisoformat(updated)
|
||||
if composed.tzinfo is None:
|
||||
raise ValueError("timezone missing")
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise SnapshotUnavailable("hosted index has invalid composition metadata") from exc
|
||||
age = (now - composed).total_seconds()
|
||||
if age < -timedelta(minutes=5).total_seconds() or age > max_age_hours * 3600:
|
||||
raise SnapshotUnavailable("hosted index composition is outside the accepted age window")
|
||||
|
||||
capabilities = payload.get("capabilities")
|
||||
if not isinstance(capabilities, list) or not capabilities:
|
||||
raise SnapshotUnavailable("hosted index has no usable capability collection")
|
||||
ids: set[str] = set()
|
||||
for item in capabilities:
|
||||
if (not isinstance(item, dict)
|
||||
or not isinstance(item.get("id"), str) or not item["id"].strip()
|
||||
or item["id"] in ids
|
||||
or not isinstance(item.get("name"), str)
|
||||
or not isinstance(item.get("summary"), str)
|
||||
or not isinstance(item.get("tags", []), list)
|
||||
or any(not isinstance(tag, str) for tag in item.get("tags", []))
|
||||
or any(item.get(key) is not None and not isinstance(item[key], str)
|
||||
for key in ("owner", "vector"))):
|
||||
raise SnapshotUnavailable("hosted index contains malformed or duplicate capabilities")
|
||||
ids.add(item["id"])
|
||||
|
||||
provenance = {
|
||||
"kind": "hosted",
|
||||
"url": url,
|
||||
"fetched_at": now.isoformat(),
|
||||
"composed_at": payload["composed_at"],
|
||||
"capability_count": len(capabilities),
|
||||
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"digest_format": "response-bytes",
|
||||
"max_age_hours": max_age_hours,
|
||||
"stale": False,
|
||||
"federation_warnings": False,
|
||||
}
|
||||
return capabilities, updated, provenance
|
||||
Loading…
Add table
Add a link
Reference in a new issue