feat(s52): webhook notifier with fail-closed opt-in gate + injectable transport

This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 19:48:02 +02:00
commit 61c4cb2d26
3 changed files with 119 additions and 1 deletions

View file

@ -17,8 +17,11 @@ from __future__ import annotations
import json
import sys
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, TextIO, runtime_checkable
from urllib.error import URLError
from urllib.request import Request, urlopen
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
from portfolio_optimiser.verdicts import Verdict
@ -80,3 +83,43 @@ class FileNotifier:
line = json.dumps(_verdict_payload(verdict), sort_keys=True) + "\n"
with self._path.open("a", encoding="utf-8") as handle:
handle.write(line)
# --- webhook (the ONLY egress point): injectable transport + fail-closed opt-in gate --------------
#: The webhook transport seam: ``(url, json-body) -> None``. Injecting a canned implementation in
#: tests keeps the suite socket-free; the default ``_urllib_post`` is the ONLY path that opens a
#: socket, and it is reachable ONLY through a gate-passed ``WebhookNotifier``.
WebhookPost = Callable[[str, str], None]
def _urllib_post(url: str, body: str) -> None:
"""The stdlib POST — the ONLY socket path in this module (mirrors ``ingest._urllib_get``)."""
request = Request(
url,
data=body.encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=10):
pass
class WebhookNotifier:
"""POSTs the verdict payload to a webhook URL — the single egress-bearing notifier.
**Fail-closed gate (no-silent-egress):** construction refuses (``NotifyRefused``) unless the
caller passes ``allow_egress=True`` explicitly, per run, in code the kwarg has no default
and is NEVER read from config (config cannot self-grant egress; mirrors ingest's
``allow_network`` gate at ``ingest.py:506``)."""
def __init__(self, url: str, *, allow_egress: bool, post: WebhookPost = _urllib_post) -> None:
if not allow_egress:
raise NotifyRefused(
"webhook notifier requires explicit per-run opt-in (allow_egress=True)"
)
self._url = url
self._post = post
def __call__(self, verdict: Verdict) -> None:
self._post(self._url, json.dumps(_verdict_payload(verdict), sort_keys=True))