- shell_hooks is the shared home: _ToolMatcherMixin (matcher compile + matches_tool), _payload_fields, _forget_home_registrations, _home_key, _utc_now_iso now serve outbound_webhooks too (copies deleted; every log string byte-identical). - shell_hooks: response parsing is a per-event dispatch table; _spawn diagnostic dict + _evaluate_result shared by the live callback and run_once; _locked_update_approvals POSIX/non-POSIX bodies merged via ExitStack. - tool_guardrails: ToolCallGuardrailConfig thresholds from a _THRESHOLD_SOURCES table (nested-wins-over-flat preserved); _int_at_least replaces _positive_int/_non_negative_int; observe_identical_call (0 refs) folded into observe_call; _halt helper for hard-stop decisions. - tool_dispatch_helpers: _plan_tool_batch_segments split into _batch_admission + close/extend helpers with the post-hoc normalization merged in. - Comment/docstring compaction keeping every stated rule.
460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""Outbound webhook notifications.
|
|
|
|
Reads ``hooks.outbound:`` from config.yaml and registers notify-only callbacks on
|
|
the plugin hook manager, so every ``invoke_hook()`` site can push lifecycle events
|
|
to external HTTP endpoints. Outbound mirror of ``gateway/platforms/webhook.py``.
|
|
|
|
* Delivery is fire-and-forget through a bounded queue and one daemon worker
|
|
thread; callbacks serialize, enqueue, and return ``None`` immediately, so a
|
|
target can never block a tool call or influence agent flow.
|
|
* Payloads are HMAC-SHA256 signed (``X-Hermes-Signature-256: sha256=<hex>`` over
|
|
the raw body) when a secret is configured.
|
|
* No consent prompt (no code runs on this machine); ``HERMES_SAFE_MODE=1`` still
|
|
skips registration. Registration is idempotent.
|
|
|
|
Config::
|
|
|
|
hooks:
|
|
outbound:
|
|
- url: https://ci.example.com/hermes-events
|
|
events: [on_session_end, subagent_stop]
|
|
secret_env: HERMES_OUTBOUND_WEBHOOK_SECRET # or inline ``secret``
|
|
matcher: "terminal|delegate_task" # pre/post_tool_call only
|
|
timeout: 10 # seconds, clamped to [1, 60]
|
|
name: ci-notify
|
|
|
|
POST body: ``{hook_event_name, profile, tool_name, tool_input, session_id, cwd,
|
|
extra, delivery_id, timestamp}``. Headers: ``Content-Type``, ``User-Agent``,
|
|
``X-Hermes-Event``, ``X-Hermes-Delivery``, ``X-Hermes-Signature-256`` (if secret).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
from urllib import error as urlerror
|
|
from urllib import request as urlrequest
|
|
|
|
from agent.shell_hooks import (
|
|
_TOOL_EVENTS as _TOOL_SCOPED_EVENTS,
|
|
_ToolMatcherMixin,
|
|
_forget_home_registrations,
|
|
_home_key,
|
|
_payload_fields,
|
|
_utc_now_iso,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_TIMEOUT_SECONDS = 10
|
|
MAX_TIMEOUT_SECONDS = 60
|
|
MAX_DELIVERY_ATTEMPTS = 2
|
|
RETRY_BACKOFF_SECONDS = 1.0
|
|
QUEUE_MAX_SIZE = 256
|
|
|
|
# (home, event, url) triples already wired in this process. Home is part of the key so a
|
|
# multiplexed gateway's secondary profiles (own plugin managers) can register identical targets.
|
|
_registered: Set[Tuple[str, str, str]] = set()
|
|
_registered_lock = threading.Lock()
|
|
|
|
_delivery_queue: "queue.Queue[Optional[Dict[str, Any]]]" = queue.Queue(
|
|
maxsize=QUEUE_MAX_SIZE
|
|
)
|
|
_worker_lock = threading.Lock()
|
|
_worker: Optional[threading.Thread] = None
|
|
|
|
|
|
@dataclass
|
|
class WebhookTarget(_ToolMatcherMixin):
|
|
"""Parsed and validated representation of one ``hooks.outbound`` entry."""
|
|
|
|
_MATCHER_KIND = "outbound webhook"
|
|
|
|
url: str
|
|
events: List[str]
|
|
name: str = ""
|
|
secret: Optional[str] = None
|
|
matcher: Optional[str] = None
|
|
timeout: int = DEFAULT_TIMEOUT_SECONDS
|
|
compiled_matcher: Optional[re.Pattern] = field(default=None, repr=False)
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return self.name or self.url
|
|
|
|
|
|
# --- Public API -----------------------------------------------------------------
|
|
|
|
def register_from_config(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]:
|
|
"""Register every configured outbound webhook on the plugin manager.
|
|
|
|
Malformed ``hooks.outbound`` means zero targets — never raises. Returns the
|
|
targets that ended up wired (deduplicated across repeat calls).
|
|
"""
|
|
if not isinstance(cfg, dict):
|
|
return []
|
|
|
|
from utils import env_var_enabled
|
|
|
|
if env_var_enabled("HERMES_SAFE_MODE"):
|
|
logger.info("HERMES_SAFE_MODE=1 — outbound webhook registration skipped")
|
|
return []
|
|
|
|
targets = iter_configured_targets(cfg)
|
|
if not targets:
|
|
return []
|
|
|
|
from hermes_cli.plugins import get_plugin_manager
|
|
|
|
manager = get_plugin_manager()
|
|
home_key = _home_key()
|
|
|
|
registered: List[WebhookTarget] = []
|
|
with _registered_lock:
|
|
for target in targets:
|
|
wired_any = False
|
|
for event in target.events:
|
|
key = (home_key, event, target.url)
|
|
if key in _registered:
|
|
continue
|
|
manager._hooks.setdefault(event, []).append(
|
|
_make_callback(event, target)
|
|
)
|
|
_registered.add(key)
|
|
wired_any = True
|
|
logger.info(
|
|
"outbound webhook registered: %s -> %s (matcher=%s, "
|
|
"timeout=%ds)",
|
|
event, target.label, target.matcher, target.timeout,
|
|
)
|
|
if wired_any:
|
|
registered.append(target)
|
|
|
|
return registered
|
|
|
|
|
|
def iter_configured_targets(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]:
|
|
"""Parse ``hooks.outbound`` without registering anything (``hermes hooks list``)."""
|
|
if not isinstance(cfg, dict):
|
|
return []
|
|
hooks_cfg = cfg.get("hooks")
|
|
return _parse_outbound_block(
|
|
hooks_cfg.get("outbound") if isinstance(hooks_cfg, dict) else None
|
|
)
|
|
|
|
|
|
def flush(timeout: float = 5.0) -> bool:
|
|
"""Block until all queued deliveries are done (or *timeout* elapses); True if drained."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
with _delivery_queue.all_tasks_done:
|
|
if _delivery_queue.unfinished_tasks == 0:
|
|
return True
|
|
time.sleep(0.02)
|
|
with _delivery_queue.all_tasks_done:
|
|
return _delivery_queue.unfinished_tasks == 0
|
|
|
|
|
|
def re_register_config_hooks() -> None:
|
|
"""Re-register outbound webhooks after a plugin force-reload cleared ``_hooks``.
|
|
|
|
Only the current home's idempotence keys are cleared so a force-reload in one
|
|
profile cannot invalidate another profile's still-live registration.
|
|
"""
|
|
from hermes_cli.config import load_config
|
|
|
|
_forget_home_registrations(_registered, _registered_lock)
|
|
register_from_config(load_config())
|
|
|
|
|
|
def reset_for_tests() -> None:
|
|
"""Clear the idempotence set and drain the queue. Test-only helper."""
|
|
with _registered_lock:
|
|
_registered.clear()
|
|
try:
|
|
while True:
|
|
_delivery_queue.get_nowait()
|
|
_delivery_queue.task_done()
|
|
except queue.Empty:
|
|
pass
|
|
|
|
|
|
# --- Config parsing -------------------------------------------------------------
|
|
|
|
def _parse_outbound_block(raw: Any) -> List[WebhookTarget]:
|
|
if raw is None:
|
|
return []
|
|
if not isinstance(raw, list):
|
|
logger.warning(
|
|
"hooks.outbound must be a list of webhook targets; got %s",
|
|
type(raw).__name__,
|
|
)
|
|
return []
|
|
targets = (_parse_single_target(i, entry) for i, entry in enumerate(raw))
|
|
return [t for t in targets if t is not None]
|
|
|
|
|
|
def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]:
|
|
from hermes_cli.plugins import VALID_HOOKS
|
|
|
|
def warn(msg: str, *args: Any) -> None:
|
|
logger.warning("hooks.outbound[%d]" + msg, index, *args)
|
|
|
|
if not isinstance(raw, dict):
|
|
warn(" must be a mapping with 'url' and 'events' keys; got %s", type(raw).__name__)
|
|
return None
|
|
|
|
url = raw.get("url")
|
|
if not isinstance(url, str) or not url.strip():
|
|
warn(" is missing a non-empty 'url'")
|
|
return None
|
|
url = url.strip()
|
|
if not url.lower().startswith(("http://", "https://")):
|
|
warn(".url must be http(s); got %r — skipped", url)
|
|
return None
|
|
if url.lower().startswith("http://"):
|
|
warn(".url uses plain http:// — payloads (including tool inputs) travel unencrypted. Prefer https.")
|
|
|
|
events_raw = raw.get("events")
|
|
valid_list = ", ".join(sorted(VALID_HOOKS))
|
|
if not isinstance(events_raw, list) or not events_raw:
|
|
warn(" needs a non-empty 'events' list (valid: %s)", valid_list)
|
|
return None
|
|
events: List[str] = []
|
|
for ev in events_raw:
|
|
if ev in VALID_HOOKS:
|
|
events.append(ev)
|
|
else:
|
|
warn(": unknown event %r ignored (valid: %s)", ev, valid_list)
|
|
if not events:
|
|
warn(" has no valid events — skipped")
|
|
return None
|
|
|
|
matcher = raw.get("matcher")
|
|
if matcher is not None and not isinstance(matcher, str):
|
|
warn(".matcher must be a string regex; ignoring")
|
|
matcher = None
|
|
if matcher is not None and not any(e in _TOOL_SCOPED_EVENTS for e in events):
|
|
warn(".matcher=%r will be ignored — matcher is only honored for pre_tool_call / post_tool_call.", matcher)
|
|
matcher = None
|
|
|
|
timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS)
|
|
try:
|
|
timeout = int(timeout_raw)
|
|
except (TypeError, ValueError):
|
|
warn(".timeout must be an int (got %r); using default %ds", timeout_raw, DEFAULT_TIMEOUT_SECONDS)
|
|
timeout = DEFAULT_TIMEOUT_SECONDS
|
|
timeout = max(1, min(timeout, MAX_TIMEOUT_SECONDS))
|
|
|
|
name = raw.get("name")
|
|
return WebhookTarget(
|
|
url=url,
|
|
events=events,
|
|
name=name.strip() if isinstance(name, str) else "",
|
|
secret=_resolve_secret(index, raw),
|
|
matcher=matcher,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
def _resolve_secret(index: int, raw: Dict[str, Any]) -> Optional[str]:
|
|
"""``secret_env`` (env var name, preferred) wins over inline ``secret``."""
|
|
secret_env = raw.get("secret_env")
|
|
if isinstance(secret_env, str) and secret_env.strip():
|
|
value = os.environ.get(secret_env.strip(), "")
|
|
if value:
|
|
return value
|
|
logger.warning(
|
|
"hooks.outbound[%d].secret_env=%r is not set in the environment "
|
|
"— deliveries will be UNSIGNED", index, secret_env.strip(),
|
|
)
|
|
return None
|
|
secret = raw.get("secret")
|
|
return secret if isinstance(secret, str) and secret else None
|
|
|
|
|
|
# --- Callback + delivery --------------------------------------------------------
|
|
|
|
def _make_callback(event: str, target: WebhookTarget):
|
|
"""Build the notify-only closure ``invoke_hook()`` calls per firing."""
|
|
|
|
def _callback(**kwargs: Any) -> None:
|
|
if event in _TOOL_SCOPED_EVENTS and not target.matches_tool(kwargs.get("tool_name")):
|
|
return None
|
|
delivery_id = uuid.uuid4().hex
|
|
try:
|
|
body = _serialize_payload(event, kwargs, delivery_id)
|
|
except Exception: # a bad payload must not hurt the loop
|
|
logger.warning(
|
|
"outbound webhook payload serialization failed (event=%s "
|
|
"target=%s)", event, target.label, exc_info=True,
|
|
)
|
|
return None
|
|
_enqueue(_build_delivery(event, target, body, delivery_id))
|
|
return None
|
|
|
|
_callback.__name__ = f"outbound_webhook[{event}:{target.label}]"
|
|
_callback.__qualname__ = _callback.__name__
|
|
return _callback
|
|
|
|
|
|
def _serialize_payload(
|
|
event: str, kwargs: Dict[str, Any], delivery_id: str,
|
|
) -> bytes:
|
|
"""Render the POST body: shell-hooks stdin shape plus delivery metadata.
|
|
|
|
``delivery_id`` (also the ``X-Hermes-Delivery`` header) and ``timestamp`` live
|
|
inside the HMAC-signed body, so they double as replay protection.
|
|
"""
|
|
# Profile resolved at fire time so a multiplexed gateway's receivers can tell which profile emitted.
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
|
|
payload = {
|
|
"hook_event_name": event,
|
|
"profile": get_active_profile_name(),
|
|
**_payload_fields(kwargs),
|
|
"delivery_id": delivery_id,
|
|
"timestamp": _utc_now_iso(),
|
|
}
|
|
return json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
|
|
|
|
def _build_delivery(
|
|
event: str, target: WebhookTarget, body: bytes, delivery_id: str,
|
|
) -> Dict[str, Any]:
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Hermes-Agent-Outbound-Webhook",
|
|
"X-Hermes-Event": event,
|
|
"X-Hermes-Delivery": delivery_id,
|
|
}
|
|
if target.secret:
|
|
digest = hmac.new(
|
|
target.secret.encode("utf-8"), body, hashlib.sha256
|
|
).hexdigest()
|
|
headers["X-Hermes-Signature-256"] = f"sha256={digest}"
|
|
return {
|
|
"url": target.url,
|
|
"label": target.label,
|
|
"event": event,
|
|
"body": body,
|
|
"headers": headers,
|
|
"timeout": target.timeout,
|
|
}
|
|
|
|
|
|
def _enqueue(delivery: Dict[str, Any]) -> None:
|
|
_ensure_worker()
|
|
try:
|
|
_delivery_queue.put_nowait(delivery)
|
|
except queue.Full:
|
|
logger.warning(
|
|
"outbound webhook queue full (%d pending) — dropping %s event "
|
|
"for %s", QUEUE_MAX_SIZE, delivery["event"], delivery["label"],
|
|
)
|
|
|
|
|
|
def _ensure_worker() -> None:
|
|
global _worker
|
|
if _worker is not None and _worker.is_alive():
|
|
return
|
|
with _worker_lock:
|
|
if _worker is not None and _worker.is_alive():
|
|
return
|
|
_worker = threading.Thread(
|
|
target=_worker_loop, name="outbound-webhooks", daemon=True,
|
|
)
|
|
_worker.start()
|
|
# Daemon worker: a short-lived process could exit right after enqueuing
|
|
# on_session_end. Drain at interpreter shutdown, bounded so a dead
|
|
# endpoint can only delay exit, never hang it.
|
|
atexit.register(flush, timeout=5.0)
|
|
|
|
|
|
def _worker_loop() -> None:
|
|
while True:
|
|
delivery = _delivery_queue.get()
|
|
try:
|
|
if delivery is not None:
|
|
_deliver(delivery)
|
|
except Exception: # pragma: no cover — defensive
|
|
logger.warning(
|
|
"outbound webhook delivery crashed (target=%s)",
|
|
delivery.get("label") if isinstance(delivery, dict) else "?",
|
|
exc_info=True,
|
|
)
|
|
finally:
|
|
_delivery_queue.task_done()
|
|
|
|
|
|
class _NoRedirectHandler(urlrequest.HTTPRedirectHandler):
|
|
"""Refuse redirects: urllib would turn a redirected POST into a body-less GET,
|
|
silently dropping the signed payload. Any 3xx surfaces as HTTPError instead."""
|
|
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
|
return None
|
|
|
|
|
|
_opener = urlrequest.build_opener(_NoRedirectHandler)
|
|
|
|
|
|
def _deliver(delivery: Dict[str, Any]) -> None:
|
|
"""POST with bounded retries: retry on connection errors and 5xx; 4xx and 3xx are final."""
|
|
last_error = ""
|
|
for attempt in range(1, MAX_DELIVERY_ATTEMPTS + 1):
|
|
req = urlrequest.Request(
|
|
delivery["url"],
|
|
data=delivery["body"],
|
|
headers=delivery["headers"],
|
|
method="POST",
|
|
)
|
|
try:
|
|
with _opener.open(req, timeout=delivery["timeout"]) as resp:
|
|
status = getattr(resp, "status", 200)
|
|
if 200 <= status < 300:
|
|
logger.debug(
|
|
"outbound webhook delivered: %s -> %s (HTTP %d)",
|
|
delivery["event"], delivery["label"], status,
|
|
)
|
|
return
|
|
last_error = f"HTTP {status}"
|
|
except urlerror.HTTPError as exc:
|
|
last_error = f"HTTP {exc.code}"
|
|
if 300 <= exc.code < 400:
|
|
logger.warning(
|
|
"outbound webhook target redirected (event=%s target=%s): "
|
|
"%s -> %s — redirects are not followed; update the "
|
|
"configured url", delivery["event"], delivery["label"],
|
|
last_error, exc.headers.get("Location", "?"),
|
|
)
|
|
return
|
|
if 400 <= exc.code < 500:
|
|
logger.warning(
|
|
"outbound webhook rejected (event=%s target=%s): %s — "
|
|
"not retrying", delivery["event"], delivery["label"],
|
|
last_error,
|
|
)
|
|
return
|
|
except Exception as exc:
|
|
last_error = str(exc) or type(exc).__name__
|
|
|
|
if attempt < MAX_DELIVERY_ATTEMPTS:
|
|
time.sleep(RETRY_BACKOFF_SECONDS * attempt)
|
|
|
|
logger.warning(
|
|
"outbound webhook delivery failed after %d attempt(s) (event=%s "
|
|
"target=%s): %s",
|
|
MAX_DELIVERY_ATTEMPTS, delivery["event"], delivery["label"], last_error,
|
|
)
|