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 json
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Protocol, TextIO, runtime_checkable
|
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
|
if TYPE_CHECKING: # verdicts imports agent_framework — keep it out of the runtime import graph
|
||||||
from portfolio_optimiser.verdicts import Verdict
|
from portfolio_optimiser.verdicts import Verdict
|
||||||
|
|
@ -80,3 +83,43 @@ class FileNotifier:
|
||||||
line = json.dumps(_verdict_payload(verdict), sort_keys=True) + "\n"
|
line = json.dumps(_verdict_payload(verdict), sort_keys=True) + "\n"
|
||||||
with self._path.open("a", encoding="utf-8") as handle:
|
with self._path.open("a", encoding="utf-8") as handle:
|
||||||
handle.write(line)
|
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))
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,31 @@ import MAF-tainted modules freely (``verdicts``) — only ``notify.py`` itself m
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from portfolio_optimiser.notify import ConsoleNotifier, FileNotifier, Notifier, _verdict_payload
|
from portfolio_optimiser.notify import (
|
||||||
|
ConsoleNotifier,
|
||||||
|
FileNotifier,
|
||||||
|
Notifier,
|
||||||
|
WebhookNotifier,
|
||||||
|
_urllib_post,
|
||||||
|
_verdict_payload,
|
||||||
|
)
|
||||||
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict, verdict_to_dict
|
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict, verdict_to_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _make_post(recorder: list[tuple[str, str]]):
|
||||||
|
"""A canned webhook transport: records ``(url, body)``, opens no socket. Local to this file
|
||||||
|
(mirrors ``test_ingest_http._make_get`` locality; never conftest)."""
|
||||||
|
|
||||||
|
def post(url: str, body: str) -> None:
|
||||||
|
recorder.append((url, body))
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
def _verdict() -> Verdict:
|
def _verdict() -> Verdict:
|
||||||
return capture_verdict(
|
return capture_verdict(
|
||||||
ProposalFeatures(
|
ProposalFeatures(
|
||||||
|
|
@ -69,3 +87,21 @@ def test_file_notifier_byte_deterministic(tmp_path) -> None:
|
||||||
assert payload["id"] == verdict.id
|
assert payload["id"] == verdict.id
|
||||||
assert payload["proposal_features"]["affected_codes"] == ["03.1", "05.2"]
|
assert payload["proposal_features"]["affected_codes"] == ["03.1", "05.2"]
|
||||||
assert not any(k in payload for k in ("date", "timestamp", "created", "ts"))
|
assert not any(k in payload for k in ("date", "timestamp", "created", "ts"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_posts_correct_payload() -> None:
|
||||||
|
"""The injected transport receives the exact ``(url, nested verdict_to_dict payload)``."""
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
verdict = _verdict()
|
||||||
|
url = "https://hooks.example.test/T000/B000/token"
|
||||||
|
WebhookNotifier(url, allow_egress=True, post=_make_post(calls))(verdict)
|
||||||
|
assert len(calls) == 1
|
||||||
|
posted_url, body = calls[0]
|
||||||
|
assert (posted_url, json.loads(body)) == (url, verdict_to_dict(verdict))
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_default_post_is_urllib_post() -> None:
|
||||||
|
"""inspect.signature proves the stdlib default WITHOUT opening a socket — ``_urllib_post`` is
|
||||||
|
the single socket path and is never called by the offline suite."""
|
||||||
|
sig = inspect.signature(WebhookNotifier.__init__)
|
||||||
|
assert sig.parameters["post"].default is _urllib_post
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,49 @@ from __future__ import annotations
|
||||||
import ast
|
import ast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser.notify import NotifyRefused, WebhookNotifier
|
||||||
|
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, capture_verdict
|
||||||
|
|
||||||
_SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser"
|
_SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser"
|
||||||
_MAF_ROOTS = {"agent_framework", "mcp"}
|
_MAF_ROOTS = {"agent_framework", "mcp"}
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict() -> Verdict:
|
||||||
|
return capture_verdict(
|
||||||
|
ProposalFeatures(
|
||||||
|
affected_codes=frozenset({"05.2"}),
|
||||||
|
measure_type="scope_reduction",
|
||||||
|
claimed_saving_nok=200_000.0,
|
||||||
|
),
|
||||||
|
"approved",
|
||||||
|
"expert reviewed (test)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- LOAD-BEARING: egress gate (both branches, same url + same recording post) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_gate_refuses_without_optin_and_posts_with_optin() -> None:
|
||||||
|
"""LOAD-BEARING (no-silent-egress): without explicit per-run opt-in the webhook notifier
|
||||||
|
refuses at CONSTRUCTION (``NotifyRefused``) and the transport is NEVER touched; with
|
||||||
|
``allow_egress=True`` the same url + same transport delivers exactly one post. Detach the
|
||||||
|
``if not allow_egress: raise`` gate → the False branch stops raising (silent egress) → RED."""
|
||||||
|
url = "https://hooks.example.test/T000/B000/secret-token"
|
||||||
|
posts: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def recording_post(post_url: str, body: str) -> None:
|
||||||
|
posts.append((post_url, body))
|
||||||
|
|
||||||
|
with pytest.raises(NotifyRefused):
|
||||||
|
WebhookNotifier(url, allow_egress=False, post=recording_post)
|
||||||
|
assert posts == [] # gate refused BEFORE any transport access
|
||||||
|
|
||||||
|
WebhookNotifier(url, allow_egress=True, post=recording_post)(_verdict())
|
||||||
|
assert len(posts) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_notify_registered_maf_free() -> None:
|
def test_notify_registered_maf_free() -> None:
|
||||||
"""Meta: notify.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free``
|
"""Meta: notify.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free``
|
||||||
actually scans it — otherwise the MAF-free claim would be green-but-dead (never checked)."""
|
actually scans it — otherwise the MAF-free claim would be green-but-dead (never checked)."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue