feat(s52): webhook notifier with fail-closed opt-in gate + injectable transport
This commit is contained in:
parent
f206dfbf65
commit
61c4cb2d26
3 changed files with 119 additions and 1 deletions
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue