"""hermes webhook — manage dynamic webhook subscriptions from the CLI.""" import hashlib import hmac import json import os import re import secrets import tempfile import time import urllib.request from pathlib import Path from typing import Dict from hermes_constants import display_hermes_home from utils import atomic_replace from hermes_cli.config import cfg_get _SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json" _SUBSCRIPTIONS_FILE_MODE = 0o600 def _hermes_home() -> Path: from hermes_constants import get_hermes_home return get_hermes_home() def _subscriptions_path() -> Path: return _hermes_home() / _SUBSCRIPTIONS_FILENAME def _load_subscriptions() -> Dict[str, dict]: path = _subscriptions_path() if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except Exception: return {} def _save_subscriptions(subs: Dict[str, dict]) -> None: path = _subscriptions_path() path.parent.mkdir(parents=True, exist_ok=True) # webhook_subscriptions.json contains per-route HMAC secrets — write # via tempfile + chmod 0o600 before the atomic rename so a permissive # umask cannot leave the secrets readable to other local users in the # window between create and rename. fd, tmp_name = tempfile.mkstemp( prefix=f".{path.name}.", suffix=".tmp", dir=path.parent, text=True, ) tmp_path = Path(tmp_name) try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(subs, fh, indent=2, ensure_ascii=False) fh.flush() os.fsync(fh.fileno()) os.chmod(tmp_path, _SUBSCRIPTIONS_FILE_MODE) atomic_replace(tmp_path, path) # Re-assert after rename in case the destination existed with a # broader mode and atomic_replace preserved it. os.chmod(path, _SUBSCRIPTIONS_FILE_MODE) except Exception: try: tmp_path.unlink(missing_ok=True) except OSError: pass raise def _get_webhook_config() -> dict: """Load webhook platform config. Returns {} if not configured.""" try: from hermes_cli.config import load_config cfg = load_config() return cfg_get(cfg, "platforms", "webhook", default={}) except Exception: return {} def _is_webhook_enabled() -> bool: return bool(_get_webhook_config().get("enabled")) def _get_webhook_base_url() -> str: wh = _get_webhook_config().get("extra", {}) host = wh.get("host") port = wh.get("port", 8644) display_host = "localhost" if not host or host in {"0.0.0.0", "::"} else host if ":" in display_host and not display_host.startswith("["): display_host = f"[{display_host}]" return f"http://{display_host}:{port}" def _setup_hint() -> str: _dhh = display_hermes_home() return f""" Webhook platform is not enabled. To set it up: 1. Run the gateway setup wizard: hermes gateway setup 2. Or manually add to {_dhh}/config.yaml: platforms: webhook: enabled: true extra: port: 8644 secret: "your-global-hmac-secret" 3. Or set environment variables in {_dhh}/.env: WEBHOOK_ENABLED=true WEBHOOK_PORT=8644 WEBHOOK_SECRET=your-global-secret Then start the gateway: hermes gateway run """ def _require_webhook_enabled() -> bool: """Check webhook is enabled. Print setup guide and return False if not.""" if _is_webhook_enabled(): return True print(_setup_hint()) return False def webhook_command(args): """Entry point for 'hermes webhook' subcommand.""" sub = getattr(args, "webhook_action", None) if not sub: print("Usage: hermes webhook {subscribe|list|remove|test}") print("Run 'hermes webhook --help' for details.") return if not _require_webhook_enabled(): return handler = _ACTIONS.get(sub) if handler is not None: handler(args) def _cmd_subscribe(args): name = args.name.strip().lower().replace(" ", "-") if not re.match(r'^[a-z0-9][a-z0-9_-]*$', name): print(f"Error: Invalid name '{name}'. Use lowercase alphanumeric with hyphens/underscores.") return subs = _load_subscriptions() is_update = name in subs secret = args.secret or secrets.token_urlsafe(32) events = [e.strip() for e in args.events.split(",")] if args.events else [] route = { "description": args.description or f"Agent-created subscription: {name}", "events": events, "secret": secret, "prompt": args.prompt or "", "skills": [s.strip() for s in args.skills.split(",")] if args.skills else [], "deliver": args.deliver or "log", "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } if getattr(args, "deliver_only", False): if route["deliver"] == "log": print( "Error: --deliver-only requires --deliver to be a real target " "(telegram, discord, slack, github_comment, etc.) — not 'log'." ) return route["deliver_only"] = True script = (getattr(args, "script", "") or "").strip() if script: route["script"] = script if args.deliver_chat_id: route["deliver_extra"] = {"chat_id": args.deliver_chat_id} subs[name] = route _save_subscriptions(subs) base_url = _get_webhook_base_url() status = "Updated" if is_update else "Created" print(f"\n {status} webhook subscription: {name}") print(f" URL: {base_url}/webhooks/{name}") print(f" Secret: {secret}") print(f" Events: {', '.join(events) or '(all)'}") print(f" Deliver: {route['deliver']}") if route.get("deliver_only"): print(" Mode: direct delivery (no agent, zero LLM cost)") if route.get("prompt"): prompt_preview = route["prompt"][:80] + ("..." if len(route["prompt"]) > 80 else "") label = "Message" if route.get("deliver_only") else "Prompt" print(f" {label}: {prompt_preview}") if route.get("script"): print(f" Script: {route['script']}") print("\n Configure your service to POST to the URL above.") print(" Use the secret for HMAC-SHA256 signature validation.") print(" The gateway must be running to receive events (hermes gateway run).\n") def _cmd_list(args): subs = _load_subscriptions() if not subs: print(" No dynamic webhook subscriptions.") print(" Create one with: hermes webhook subscribe ") return base_url = _get_webhook_base_url() print(f"\n {len(subs)} webhook subscription(s):\n") for name, route in subs.items(): events = ", ".join(route.get("events", [])) or "(all)" deliver = route.get("deliver", "log") if route.get("deliver_only"): deliver = f"{deliver} (direct — no agent)" desc = route.get("description", "") print(f" ◆ {name}") if desc: print(f" {desc}") print(f" URL: {base_url}/webhooks/{name}") print(f" Events: {events}") print(f" Deliver: {deliver}") if route.get("script"): print(f" Script: {route['script']}") print() def _cmd_remove(args): name = args.name.strip().lower() subs = _load_subscriptions() if name not in subs: print(f" No subscription named '{name}'.") print(" Note: Static routes from config.yaml cannot be removed here.") return del subs[name] _save_subscriptions(subs) print(f" Removed webhook subscription: {name}") def _cmd_test(args): """Send a test POST to a webhook route.""" name = args.name.strip().lower() subs = _load_subscriptions() if name not in subs: print(f" No subscription named '{name}'.") return secret = subs[name].get("secret", "") base_url = _get_webhook_base_url() url = f"{base_url}/webhooks/{name}" payload = args.payload or '{"test": true, "event_type": "test", "message": "Hello from hermes webhook test"}' sig = "sha256=" + hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest() print(f" Sending test POST to {url}") try: req = urllib.request.Request( url, data=payload.encode(), headers={ "Content-Type": "application/json", "X-Hub-Signature-256": sig, "X-GitHub-Event": "test", }, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: body = resp.read().decode() print(f" Response ({resp.status}): {body}") except Exception as e: print(f" Error: {e}") print(" Is the gateway running? (hermes gateway run)") _ACTIONS = { "subscribe": _cmd_subscribe, "add": _cmd_subscribe, "list": _cmd_list, "ls": _cmd_list, "remove": _cmd_remove, "rm": _cmd_remove, "test": _cmd_test, }