""" Outbound webhook notifications. Reads the ``hooks.outbound:`` list from ``config.yaml`` and registers notify-only callbacks on the existing plugin hook manager, so every ``invoke_hook()`` site can push lifecycle events to external HTTP endpoints — CI systems, dashboards, other agents — with zero changes to call sites and zero polling on the receiving end. This is the outbound mirror of the inbound webhook platform (``gateway/platforms/webhook.py``): inbound wakes Hermes when the world changes; outbound tells the world when Hermes does something. Design notes ------------ * Delivery is fire-and-forget through a bounded in-process queue and a single daemon worker thread. ``invoke_hook()`` runs inside the agent loop, so callbacks must never block on network I/O — they serialize, enqueue, and return ``None`` immediately. Outbound targets can never block a tool call, inject context, or otherwise influence agent flow. * Payloads are signed with HMAC-SHA256 (GitHub-style ``X-Hermes-Signature-256: sha256=`` over the raw body) when a secret is configured. Receivers verify exactly like they verify GitHub webhooks. * No consent prompt: unlike shell hooks, an outbound target executes no code on this machine — it POSTs JSON to a URL the user themselves put in config. ``HERMES_SAFE_MODE=1`` still skips registration, matching plugins / MCP / shell hooks. * Registration is idempotent — safe to invoke from both the CLI entry point and the gateway entry point. Config schema (``~/.hermes/config.yaml``):: hooks: outbound: - url: https://ci.example.com/hermes-events events: [on_session_end, subagent_stop] # secret literal (discouraged) or env var name (preferred): secret_env: HERMES_OUTBOUND_WEBHOOK_SECRET # optional regex, honored for pre/post_tool_call only: matcher: "terminal|delegate_task" timeout: 10 # per-attempt seconds, clamped to [1, 60] name: ci-notify # optional label for logs / `hermes hooks list` Wire format (POST body):: { "hook_event_name": "on_session_end", "tool_name": null, "tool_input": null, "session_id": "sess_abc123", "cwd": "/home/user/project", "extra": {...}, # event-specific kwargs "delivery_id": "3f2c...", # uuid4, unique per POST "timestamp": "2026-07-22T14:00:00Z" } Headers:: Content-Type: application/json User-Agent: Hermes-Agent-Outbound-Webhook X-Hermes-Event: X-Hermes-Delivery: X-Hermes-Signature-256: sha256= # only when secret set """ 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 datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple from urllib import error as urlerror from urllib import request as urlrequest 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 # Events whose ``matcher`` field is honored (mirrors shell hooks). _TOOL_SCOPED_EVENTS = {"pre_tool_call", "post_tool_call"} # kwargs promoted to top-level payload keys (mirrors shell hooks wire). _TOP_LEVEL_PAYLOAD_KEYS = {"tool_name", "args", "session_id", "parent_session_id"} # (home, event, url) triples already wired to the plugin manager in this # process. Home is part of the key so a multiplexed gateway's secondary # profiles — each with their own plugin manager (see # hermes_cli.plugins.get_plugin_manager) — can register identical webhook # targets without the first profile's registration shadowing the rest. _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: """Parsed and validated representation of one ``hooks.outbound`` entry.""" 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) def __post_init__(self) -> None: if isinstance(self.matcher, str): stripped = self.matcher.strip() self.matcher = stripped if stripped else None if self.matcher: try: self.compiled_matcher = re.compile(self.matcher) except re.error as exc: logger.warning( "outbound webhook matcher %r is invalid (%s) — treating " "as literal equality", self.matcher, exc, ) self.compiled_matcher = None @property def label(self) -> str: return self.name or self.url def matches_tool(self, tool_name: Optional[str]) -> bool: if not self.matcher: return True if tool_name is None: return False if self.compiled_matcher is not None: return self.compiled_matcher.fullmatch(tool_name) is not None return tool_name == self.matcher # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def register_from_config(cfg: Optional[Dict[str, Any]]) -> List[WebhookTarget]: """Register every configured outbound webhook on the plugin manager. ``cfg`` is the full parsed config dict. Missing, empty, or malformed ``hooks.outbound`` is treated as zero targets — config parsing never raises, because a broken webhook entry must not crash the agent. Returns the targets that ended up wired (deduplicated across repeat calls, so the CLI and gateway can both invoke this safely). """ 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 [] hooks_cfg = cfg.get("hooks") targets = _parse_outbound_block( hooks_cfg.get("outbound") if isinstance(hooks_cfg, dict) else None ) if not targets: return [] from hermes_cli.plugins import get_plugin_manager from hermes_constants import get_hermes_home manager = get_plugin_manager() home_key = str(get_hermes_home().expanduser().resolve()) 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. Used by ``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). Returns ``True`` when the queue fully drained. Test/shutdown helper.""" 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 from config after a plugin force-reload. Mirrors ``agent.shell_hooks.re_register_config_hooks``: config-owned outbound-webhook callbacks live in the same ``_hooks`` dict that ``PluginManager.discover_and_load(force=True)`` clears via ``unload()``, so without this the force-reloaded profile's outbound webhooks go silently inert (#92682 review). 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 from hermes_constants import get_hermes_home home_key = str(get_hermes_home().expanduser().resolve()) with _registered_lock: _registered.difference_update( {key for key in _registered if key[0] == home_key} ) 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: List[WebhookTarget] = [] for i, entry in enumerate(raw): target = _parse_single_target(i, entry) if target is not None: targets.append(target) return targets def _parse_single_target(index: int, raw: Any) -> Optional[WebhookTarget]: from hermes_cli.plugins import VALID_HOOKS if not isinstance(raw, dict): logger.warning( "hooks.outbound[%d] must be a mapping with 'url' and 'events' " "keys; got %s", index, type(raw).__name__, ) return None url = raw.get("url") if not isinstance(url, str) or not url.strip(): logger.warning("hooks.outbound[%d] is missing a non-empty 'url'", index) return None url = url.strip() if not url.lower().startswith(("http://", "https://")): logger.warning( "hooks.outbound[%d].url must be http(s); got %r — skipped", index, url, ) return None if url.lower().startswith("http://"): logger.warning( "hooks.outbound[%d].url uses plain http:// — payloads (including " "tool inputs) travel unencrypted. Prefer https.", index, ) events_raw = raw.get("events") if not isinstance(events_raw, list) or not events_raw: logger.warning( "hooks.outbound[%d] needs a non-empty 'events' list (valid: %s)", index, ", ".join(sorted(VALID_HOOKS)), ) return None events: List[str] = [] for ev in events_raw: if ev in VALID_HOOKS: events.append(ev) else: logger.warning( "hooks.outbound[%d]: unknown event %r ignored (valid: %s)", index, ev, ", ".join(sorted(VALID_HOOKS)), ) if not events: logger.warning( "hooks.outbound[%d] has no valid events — skipped", index, ) return None matcher = raw.get("matcher") if matcher is not None and not isinstance(matcher, str): logger.warning( "hooks.outbound[%d].matcher must be a string regex; ignoring", index, ) matcher = None if matcher is not None and not any(e in _TOOL_SCOPED_EVENTS for e in events): logger.warning( "hooks.outbound[%d].matcher=%r will be ignored — matcher is only " "honored for pre_tool_call / post_tool_call.", index, matcher, ) matcher = None timeout_raw = raw.get("timeout", DEFAULT_TIMEOUT_SECONDS) try: timeout = int(timeout_raw) except (TypeError, ValueError): logger.warning( "hooks.outbound[%d].timeout must be an int (got %r); using " "default %ds", index, timeout_raw, DEFAULT_TIMEOUT_SECONDS, ) timeout = DEFAULT_TIMEOUT_SECONDS timeout = max(1, min(timeout, MAX_TIMEOUT_SECONDS)) secret = _resolve_secret(index, raw) name = raw.get("name") if not isinstance(name, str): name = "" return WebhookTarget( url=url, events=events, name=name.strip(), secret=secret, 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") if isinstance(secret, str) and secret: return secret return 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: if 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: # defensive — 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. Same top-level shape as shell hooks' stdin (documented in :mod:`agent.shell_hooks`), plus delivery metadata. ``delivery_id`` is shared with the ``X-Hermes-Delivery`` header so receivers can dedupe on either — and since it (plus ``timestamp``) lives inside the HMAC-signed body, it doubles as replay protection. """ extras = {k: v for k, v in kwargs.items() if k not in _TOP_LEVEL_PAYLOAD_KEYS} try: cwd = str(Path.cwd()) except OSError: cwd = "" # Resolved at fire time from the bound home so a multiplexed gateway's # receivers can tell which profile emitted the event (#92674). from hermes_cli.profiles import get_active_profile_name payload = { "hook_event_name": event, "profile": get_active_profile_name(), "tool_name": kwargs.get("tool_name"), "tool_input": kwargs.get("args") if isinstance(kwargs.get("args"), dict) else None, "session_id": kwargs.get("session_id") or kwargs.get("parent_session_id") or "", "cwd": cwd, "extra": extras, "delivery_id": delivery_id, "timestamp": datetime.now(tz=timezone.utc) .isoformat() .replace("+00:00", "Z"), } 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() # The worker is a daemon thread, so a short-lived process (a `-q` # CLI run, a cron session) can exit right after enqueuing the # final events — silently dropping on_session_end, the headline # use case. Drain the queue 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 to follow redirects. urllib's default handler converts a redirected POST into a body-less GET — the signed payload would be silently dropped and the headers re-sent to a location the user never configured. Treat any 3xx as a delivery failure instead (surfaced as HTTPError by returning None). """ 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. Retries on connection errors and 5xx; 4xx is the receiver telling us the request itself is wrong — no retry. 3xx redirects are never followed (misconfiguration — fix the URL).""" 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, )