Harden the PEP harness and KeyCape registration request

Close remaining in-repo APPROVAL-WP-0002 gaps: drive GH-DEC-2026-003 against
the real HTTP surface, fail closed on JWT/human-consume/static-token paths,
treat audit 200 duplicates as drained, and ask KeyCape for the production
audience and client grants.

Assistant: grok
Assistant-Session: 01a06253-e557-7971-93d9-4f4c2cfbf455
This commit is contained in:
tegwick 2026-09-02 15:46:06 +02:00
parent 2bd2d19a98
commit 2370f69927
11 changed files with 588 additions and 46 deletions

View file

@ -3,17 +3,37 @@
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,
@ -23,17 +43,23 @@ class ApprovalHTTPClient:
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 _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> dict[str, Any]:
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,
@ -42,19 +68,29 @@ class ApprovalHTTPClient:
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
response = self.opener(request, timeout=self.timeout_seconds)
status = int(response.getcode())
raw = response.read(256 * 1024 + 1)
response.close()
except (HTTPError, URLError, OSError) as exc:
raise ApprovalProtocolError(type(exc).__name__) from exc
if status != 200 or len(raw) > 256 * 1024:
raise ApprovalProtocolError(f"approval endpoint returned status {status}")
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)
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):
@ -62,18 +98,32 @@ class ApprovalHTTPClient:
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
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="")
return self._request(
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",
{"request_digest": request_digest, "decision_id": decision_id},
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:
@ -81,7 +131,7 @@ class ProtectedActionHarness:
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.
side effect. The consume response is mutation evidence, never a permission.
"""
def __init__(self, client: ApprovalHTTPClient) -> None:
@ -106,9 +156,6 @@ class ProtectedActionHarness:
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 (
consumed.get("status") != "consumed"
or consumed.get("request_digest") != request_digest
):
raise ApprovalProtocolError("approval consumption was not confirmed")
if "effect" in consumed or "allow" in consumed or "deny" in consumed:
raise ApprovalProtocolError("consume response is not a permission")
return side_effect()