"""Public API for the agent-permissions library.""" from __future__ import annotations import json from dataclasses import dataclass from typing import Optional from .config import get_settings from .errors import ( AgentPermissionsError, InteractionValidationError, ManifestFetchError, ManifestNotFoundError, ManifestValidationError, ) from .io import ManifestDiscoveryResult, fetch_bytes, resolve_manifest_urls from .models import Manifest, ValidationResult from .prompt import build_prompt_instructions from .validation import validate_interaction as _validate_interaction from .validation import validate_manifest_payload __all__ = [ "fetch_manifest", "discover_manifest", "parse_manifest_from_html", "validate_interaction", "ensure_interaction_allowed", "build_prompt_instructions", "Manifest", "ValidationResult", ] @dataclass class ManifestLoadResult: manifest: Manifest manifest_url: str cache_hit: bool method: str details: dict def _cache_key(url: str) -> str: return f"manifest::{url}" def fetch_manifest( url: str, *, client=None, use_cache: bool = True, ) -> Manifest: """Fetch and validate a manifest located at the provided URL.""" load_result = _load_manifest(url, client=client, use_cache=use_cache, method="direct") return load_result.manifest def discover_manifest( page_url: str, *, html: Optional[str] = None, client=None, use_cache: bool = True, throw_if_missing: bool = False, ) -> Optional[ManifestDiscoveryResult]: """Locate and download a manifest associated with a webpage.""" # First attempt to parse embedded manifest from the HTML if provided. if html is not None: embedded = _extract_manifest_from_html(html, base_url=page_url) if embedded.manifest is not None: return embedded errors: list[tuple[str, str]] = [] for candidate in resolve_manifest_urls(page_url, html=html): if candidate.manifest_url is None: continue try: load_result = _load_manifest( candidate.manifest_url, client=client, use_cache=use_cache, method=candidate.method, ) candidate.manifest = load_result.manifest candidate.details = {**candidate.details, **load_result.details, "cache_hit": load_result.cache_hit} return candidate except AgentPermissionsError as exc: errors.append((candidate.manifest_url, str(exc))) if throw_if_missing: error_payload = [{"url": url, "error": err} for url, err in errors] raise ManifestNotFoundError(f"No manifest discovered for {page_url}: {error_payload}") return None def parse_manifest_from_html( html: str, base_url: Optional[str] = None, ) -> ManifestDiscoveryResult: """Search for a manifest reference within the provided HTML.""" return _extract_manifest_from_html(html, base_url=base_url) def _extract_manifest_from_html( html: str, *, base_url: Optional[str] = None, ) -> ManifestDiscoveryResult: from .io.discovery import find_manifest_in_html # Local import to avoid cycles return find_manifest_in_html(html, base_url=base_url) def validate_interaction(manifest: Manifest, *, verb: str, element) -> ValidationResult: """Return a ValidationResult for the given interaction.""" return _validate_interaction(manifest, verb=verb, element=element) def ensure_interaction_allowed(manifest: Manifest, *, verb: str, element) -> ValidationResult: """Validate an interaction and raise if it is denied.""" result = validate_interaction(manifest, verb=verb, element=element) if not result.allowed: raise InteractionValidationError("; ".join(result.reasons) or "Interaction denied by manifest") return result def _load_manifest( url: str, *, client=None, use_cache: bool, method: str, ) -> ManifestLoadResult: settings = get_settings() cache_hit = False manifest: Optional[Manifest] = None cache_details: dict = {} if use_cache and settings.enable_cache: cached = settings.cache.get(_cache_key(url)) if cached is not None: cache_hit = True manifest = cached if manifest is None: raw_bytes = fetch_bytes(url, client=client) try: payload = json.loads(raw_bytes.decode("utf-8")) except UnicodeDecodeError as exc: raise ManifestFetchError(f"Failed to decode manifest as UTF-8 from {url}") from exc except json.JSONDecodeError as exc: raise ManifestValidationError(f"Manifest at {url} is not valid JSON: {exc}") from exc manifest = validate_manifest_payload(payload) if use_cache and settings.enable_cache: settings.cache.set(_cache_key(url), manifest, ttl_seconds=settings.cache_ttl_seconds) cache_details["schema_version"] = manifest.metadata.schema_version return ManifestLoadResult( manifest=manifest, manifest_url=url, cache_hit=cache_hit, method=method, details=cache_details, )