"""Fail-closed PEP client and sequencing harness for GH-DEC-2026-003.""" from __future__ import annotations import json import re from pathlib import Path from typing import Any, Callable from urllib.error import HTTPError, URLError from urllib.parse import quote from urllib.request import Request, urlopen from .binding import DIGEST_RE _MAX_BODY = 256 * 1024 _ID_RE = re.compile(r"^[A-Za-z0-9:._-]+$") class ApprovalProtocolError(RuntimeError): pass def _status_message(status: int) -> str: if status == 409: return "approval consume conflict" if status == 404: return "approval not found" if status in {401, 403}: return "approval consume unauthorized" if status == 503: return "approval-engine unavailable" if status == 0: return "approval-engine unreachable" return f"approval endpoint returned status {status}" class ApprovalHTTPClient: def __init__( self, base_url: str, token_file: str | Path, *, timeout_seconds: float = 3, opener: Callable[..., Any] = urlopen, ) -> None: if not base_url or not base_url.startswith(("http://", "https://")): raise ApprovalProtocolError("approval-engine URL is missing or invalid") self.base_url = base_url.rstrip("/") self.token_file = Path(token_file) self.timeout_seconds = timeout_seconds self.opener = opener def _token(self) -> str: token = self.token_file.read_text(encoding="utf-8").strip() if not token or any(ch.isspace() for ch in token): raise ApprovalProtocolError("approval credential is unavailable") return token def _request( self, method: str, path: str, body: dict[str, Any] | None = None ) -> dict[str, Any]: token = self._token() encoded = None if body is None else json.dumps(body).encode("utf-8") request = Request( self.base_url + path, data=encoded, method=method, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json", }, ) try: response = self.opener(request, timeout=self.timeout_seconds) try: status = int(response.getcode()) raw = response.read(_MAX_BODY + 1) finally: response.close() except HTTPError as exc: status = int(getattr(exc, "code", 0) or 0) try: exc.read(_MAX_BODY) except Exception: pass raise ApprovalProtocolError(_status_message(status)) from None except (URLError, TimeoutError, OSError): raise ApprovalProtocolError("approval-engine unreachable") from None if status != 200 or len(raw) > _MAX_BODY: raise ApprovalProtocolError(_status_message(status if status != 200 else 502)) try: result = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise ApprovalProtocolError("approval endpoint returned invalid JSON") from exc if not isinstance(result, dict): raise ApprovalProtocolError("approval endpoint returned invalid payload") return result def claim(self, approval_id: str) -> dict[str, Any]: if not _ID_RE.fullmatch(approval_id): raise ApprovalProtocolError("approval id is invalid") encoded_id = quote(approval_id, safe="") return self._request("GET", f"/v1/approvals/{encoded_id}/claim") def consume( self, approval_id: str, request_digest: str, decision_id: str | None = None ) -> dict[str, Any]: if not _ID_RE.fullmatch(approval_id): raise ApprovalProtocolError("approval id is invalid") if not DIGEST_RE.fullmatch(request_digest): raise ApprovalProtocolError("approval consume requires the canonical request digest") encoded_id = quote(approval_id, safe="") payload: dict[str, str] = {"request_digest": request_digest} if decision_id: payload["decision_id"] = decision_id result = self._request( "POST", f"/v1/approvals/{encoded_id}/consume", payload, ) if result.get("status") != "consumed": raise ApprovalProtocolError("approval consumption was not confirmed") if result.get("request_digest") != request_digest: raise ApprovalProtocolError("approval consume digest does not match the request") return result class ProtectedActionHarness: """Sequence a supplied PDP decision and dry-run/protected callback. The decision callback owns authorization. This class only enforces that a fresh approval claim precedes it and a successful CAS consume precedes the side effect. The consume response is mutation evidence, never a permission. """ def __init__(self, client: ApprovalHTTPClient) -> None: self.client = client def execute( self, approval_id: str, request_digest: str, decide: Callable[[dict[str, Any]], dict[str, Any]], side_effect: Callable[[], Any], ) -> Any: claim = self.client.claim(approval_id) if claim.get("valid_now") is not True or claim.get("consumed") is not False: raise ApprovalProtocolError("approval claim is not valid for use") decision = decide(claim) if decision.get("effect") != "ALLOW": raise ApprovalProtocolError("authorization decision did not allow") decision_id = decision.get("decision_id") if not isinstance(decision_id, str) or not decision_id: raise ApprovalProtocolError("authorization decision lacks decision_id") if decision.get("request_digest") != request_digest: raise ApprovalProtocolError("authorization decision digest does not match") consumed = self.client.consume(approval_id, request_digest, decision_id) if "effect" in consumed or "allow" in consumed or "deny" in consumed: raise ApprovalProtocolError("consume response is not a permission") return side_effect()