NousResearch/hermes-agent

Adapter bind address and security hardening fixes

Cluster 4260 · gateway. Data as of

1 open item. Fix merged in #65482 on 16 Jul 2026. Check it covers your case.

The short answer

1open issue
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 0 issues already closed.
Fix event
A fix was merged to main in PR #65482. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
1 issue and 0 PRs.

These issues and PRs address default network bind addresses for SMS and webhook adapters (loopback vs. dual-stack vs. 0.0.0.0), along with related security vulnerability patches and socket option adjustments. The members share overlapping but not fully unified themes around network exposure and security hardening.

Open issues1

  • Issue#4260Security: SMS and Webhook adapters bind to 0.0.0.0 by default

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #19745 fix(sms): mark missing-config errors as non-retryable; default bind to 127.0.0.1

    merged to main

    files: gateway/platforms/sms.py, tests/gateway/test_sms.py

  • #63711 fix(webhook): default to dual-stack bind so 6PN (IPv6) can reach the adapter

    merged to main

    files: gateway/platforms/webhook.py, hermes_cli/webhook.py, skills/autonomous-ai-agents/hermes-agent/references/webhooks.md, tests/gateway/test_webhook_adapter.py, tests/hermes_cli/test_webhook_cli.py, website/docs/guides/webhook-github-pr-review.md

    Show the diff
    • gateway/platforms/webhook.py
      @@ -77,7 +77,25 @@
           "qqbot", "yuanbao",
       }
       
      -DEFAULT_HOST = "0.0.0.0"
      +# Default bind host. ``None`` tells aiohttp/asyncio's ``create_server`` to bind
      +# BOTH address families (IPv4 + IPv6) — the portable dual-stack default.
      +#
      +# Why not "0.0.0.0" (the old default) or "::"?
      +#   - "0.0.0.0" binds IPv4 ONLY. On IPv6-only private networks — notably Fly.io
      +#     6PN, where an agent's ``<app>.internal`` name resolves to an ``fdaa:…``
      +#     IPv6 address — an IPv4-only listener is unreachable. That is exactly why
      +#     hosted-agent webhook routes were publicly unreachable: the edge router
      +#     reverse-proxies to ``<app>.internal:8644`` over 6PN (IPv6) but the adapter
      +#     was listening on 0.0.0.0 (v4 only) → connection refused.
      +#   - "::" is NOT a safe fix: on hosts where the kernel sets IPV6_V6ONLY=1
      +#     (verified on Fly machines), binding "::" yields an IPv6-ONLY socket, which
      +#     then breaks the IPv4 loopback health check (``curl 127.0.0.1:8644/health``)
      +#     and the AF_INET port-conflict probe in connect().
      +#   - ``None`` asks the event loop to create a listening socket per resolved
      +#     family, so both 127.0.0.1 (v4) and the 6PN fdaa (v6) are served regardless
      +#     of the bindv6only sysctl. Users can still pin a specific host via
      +#     ``platforms.webhook.extra.host``.
      +DEFAULT_HOST = None
       DEFAULT_PORT = 8644
       _INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
       _DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
      @@ -93,7 +111,7 @@
       })
       
       
      -def _is_loopback_host(host: str) -> bool:
      +def _is_loopback_host(host: Optional[str]) -> bool:
           """True when `host` binds only to the local machine.
       
           Covers IPv4 loopback, the standard `localhost` alias, IPv6 loopback in
      @@ -116,7 +134,11 @@ class WebhookAdapter(BasePlatformAdapter):
       
           def __init__(self, config: PlatformConfig):
               super().__init__(config, Platform.WEBHOOK)
      -        self._host: str = config.extra.get("host", DEFAULT_HOST)
      +        # ``host`` may be None (dual-stack default) or a user-pinned string.
      +        # A config value of empty string / null is normalised to None so it
      +        # also means "bind all families" rather than an invalid "" host.
      +        _cfg_host = config.extra.get("host", DEFAULT_HOST)
      +        self._host: Optional[str] = _cfg_host or None
               self._port: int = int(config.extra.get("port", DEFAULT_PORT))
               self._global_secret: str = config.extra.get("secret", "")
               self._static_routes: Dict[str, dict] = config.extra.get("routes", {})
      @@ -225,27 +247,39 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
                   "/p/{profile}/webhooks/{route_name}", self._handle_webhook
               )
       
      -        # Port conflict detection — fail fast if port is already in use
      -        import socket as _socket
      -        try:
      -            with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
      -                _s.settimeout(1)
      -                _s.connect(('127.0.0.1', self._port))
      -            logger.error('[webhook] Port %d already in use. Set a different port in config.yaml: platforms.webhook.port', self._port)
      -            return False
      -        except (ConnectionRefusedError, OSError):
      -            pass  # port is free
      -
               self._runner = web.AppRunner(app)
               await self._runner.setup()
      -        site = web.TCPSite(self._runner, self._host, self._port)
      -        await site.start()
      +        # Do not probe only one address family before binding. With the
      +        # dual-stack default, an IPv6-only listener can already own this port
      +        # while 127.0.0.1 still looks free. Also disable SO_REUSEADDR for the
      +        # listener: on macOS, two wildcard/specific sockets with SO_REUSEADDR
      +        # can silently split traffic while both servers report success.
      +        site = web.TCPSite(
      +            self._runner,
      +            self._host,
      +            self._port,
      +            reuse_address=False,
      +        )
      +        try:
      +            await site.start()
      +        except OSError as exc:
      +            await self._runner.cleanup()
      +            self._runner = None
      +            logger.error(
      +                "[webhook] Could not bind %s:%d: %s. "
      +                "Set a different host or port in config.yaml under "
      +                "platforms.webhook.extra.",
      +                self._host or "all IPv4+IPv6 interfaces",
      +                self._port,
      +                exc,
      +            )
      +            return False
               self._mark_connected()
       
               route_names = ", ".join(self._routes.keys()) or "(none configured)"
               logger.info(
                   "[webhook] Listening on %s:%d — routes: %s",
      -            self._host,
      +            self._host or "* (all interfaces, IPv4+IPv6)",
                   self._port,
                   route_names,
               )
    • hermes_cli/webhook.py
      @@ -96,9 +96,11 @@ def _is_webhook_enabled() -> bool:
       
       def _get_webhook_base_url() -> str:
           wh = _get_webhook_config().get("extra", {})
      -    host = wh.get("host", "0.0.0.0")
      +    host = wh.get("host")
           port = wh.get("port", 8644)
      -    display_host = "localhost" if host == "0.0.0.0" else host
      +    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}"
       
       
      @@ -115,7 +117,6 @@ def _setup_hint() -> str:
              webhook:
                enabled: true
                extra:
      -           host: "0.0.0.0"
                  port: 8644
                  secret: "[redacted:secret-assignment]"
       
    • skills/autonomous-ai-agents/hermes-agent/references/webhooks.md
      @@ -24,11 +24,13 @@ platforms:
         webhook:
           enabled: true
           extra:
      -      host: "0.0.0.0"
             port: 8644
             secret: "[redacted:secret-assignment]"
       ```
       
      +Omitting `host` uses the dual-stack default and listens on both IPv4 and IPv6.
      +Set a specific address only when you intentionally want to restrict the bind.
      +
       ### Option 3: Environment variables
       Add to `${HERMES_HOME:-~/.hermes}/.env`:
       ```bash
    • tests/gateway/test_webhook_adapter.py
      @@ -19,6 +19,7 @@
       import hashlib
       import hmac
       import json
      +import socket
       import time
       from collections import deque
       from unittest.mock import AsyncMock, MagicMock, patch
      @@ -1525,3 +1526,118 @@ async def test_connect_allows_real_secret_on_public_bind(self):
                   assert result is True
               finally:
                   await adapter.disconnect()
      +
      +
      +class TestDualStackBind:
      +    """The default bind host must serve BOTH IPv4 and IPv6.
      +
      +    Regression guard for the hosted-agent webhook reachability bug: Fly.io 6PN
      +    (the private network the edge router reverse-proxies webhook traffic over)
      +    is IPv6-only — an agent's ``<app>.internal`` name resolves to an ``fdaa:…``
      +    address. The adapter used to default to ``host="0.0.0.0"`` (IPv4 only), so
      +    the router's dial to ``<app>.internal:8644`` hit an address nothing was
      +    listening on → connection refused → public webhooks unreachable.
      +
      +    The fix is ``DEFAULT_HOST = None`` (dual-stack). ``"::"`` is NOT a valid
      +    substitute: on hosts with the ``bindv6only`` sysctl set (verified on Fly
      +    machines) it yields an IPv6-ONLY socket, which would then break the IPv4
      +    loopback health check and the AF_INET port-conflict probe.
      +    """
      +
      +    def test_default_host_is_none_for_dual_stack(self):
      +        """The module default is None (bind all families), not 0.0.0.0/::."""
      +        from gateway.platforms.webhook import DEFAULT_HOST
      +        assert DEFAULT_HOST is None
      +
      +    def test_missing_host_key_resolves_to_none(self):
      +        """Config with no host key → dual-stack (None), not a literal string."""
      +        cfg = PlatformConfig(enabled=True, extra={"port": 0, "routes": {}})
      +        adapter = WebhookAdapter(cfg)
      +        assert adapter._host is None
      +
      +    @pytest.mark.parametrize("empty", ["", None])
      +    def test_empty_host_normalises_to_none(self, empty):
      +        """An explicit empty-string/null host means dual-stack, not host=''.
      +
      +        Guards the old footgun where host='' was passed straight to TCPSite
      +        AND treated as non-loopback — now it collapses to the None default.
      +        """
      +        adapter = _make_adapter(host=empty, port=0)
      +        assert adapter._host is None
      +
      +    def test_pinned_host_is_preserved(self):
      +        """A user can still pin a specific bind host via config.extra.host."""
      +        adapter = _make_adapter(host="127.0.0.1", port=0)
      +        assert adapter._host == "127.0.0.1"
      +
      +    @pytest.mark.asyncio
      +    async def test_default_bind_serves_both_families(self):
      +        """Binding the real server with the default host opens v4 AND v6 sockets.
      +
      +        This is the behavioural proof: with host=None, asyncio.create_server
      +        opens a listening socket per resolved family, so both 127.0.0.1 (v4)
      +        and ::1 (v6) are reachable — exactly what 6PN needs. Uses a real bind
      +        on an OS-assigned port (no mock) and inspects the runner's addresses.
      +        """
      +        # Build config WITHOUT a host key so the real DEFAULT_HOST (None)
      +        # applies — _make_adapter's helper injects host="0.0.0.0" by default,
      +        # which would mask the dual-stack default under test here.
      +        cfg = PlatformConfig(
      +            enabled=True,
      +            extra={
      +                "port": 0,
      +                "routes": {"r1": {"secret": "[redacted:secret-assignment]", "prompt": "x"}},
      +            },
      +        )
      +        adapter = WebhookAdapter(cfg)
      +        assert adapter._host is None
      +        try:
      +            with patch.object(adapter, "_reload_dynamic_routes"):
      +                result = await adapter.connect()
      +            assert result is True
      +            # runner.addresses lists one bound address per listening socket.
      +            # An IPv6 sockaddr is a 4-tuple (host, port, flowinfo, scopeid);
      +            # an IPv4 sockaddr is a 2-tuple (host, port). With the dual-stack
      +            # default we expect BOTH — that is precisely what makes the adapter
      +            # reachable over 6PN (v6) AND on the loopback health check (v4).
      +            addrs = list(adapter._runner.addresses)  # type: ignore[union-attr]
      +            has_v6 = any(len(a) == 4 for a in addrs)
      +            has_v4 = any(len(a) == 2 for a in addrs)
      +            assert has_v4, f"IPv4 bind missing — got {addrs}"
      +            assert has_v6, (
      +                f"IPv6 bind missing (the 6PN reachability bug) — got {addrs}"
      +            )
      +        finally:
      +            await adapter.disconnect()
      +
      +    @pytest.mark.asyncio
      +    async def test_default_bind_rejects_existing_ipv6_listener(self):
      +        """A specific IPv6 listener must block the wildcard dual-stack bind."""
      +        blocker = await asyncio.start_server(
      +            lambda _reader, _writer: None,
      +            host="::1",
      +            port=0,
      +            family=socket.AF_INET6,
      +            reuse_address=False,
      +        )
      +        port = blocker.sockets[0].getsockname()[1]
      +        cfg = PlatformConfig(
      +            enabled=True,
      +            extra={
      +                "port": port,
      +                "routes": {
      +                    "r1": {"secret": "[redacted:secret-assignment]", "prompt": "x"}
      +                },
      +            },
      +        )
      +        adapter = WebhookAdapter(cfg)
      +        try:
      +            with patch.object(adapter, "_reload_dynamic_routes"):
      +                result = await adapter.connect()
      +            assert result is False
      +            assert adapter._runner is None
      +            assert adapter.is_connected is False
      +        finally:
      +            await adapter.disconnect()
      +            blocker.close()
      +            await blocker.wait_closed()
    • tests/hermes_cli/test_webhook_cli.py
      @@ -8,6 +8,7 @@
       
       from hermes_cli.webhook import (
           webhook_command,
      +    _get_webhook_base_url,
           _load_subscriptions,
           _save_subscriptions,
           _subscriptions_path,
      @@ -41,6 +42,23 @@ def _make_args(**kwargs):
           return Namespace(**defaults)
       
       
      [redacted:email]("host", [None, "", "0.0.0.0", "::"])
      +def test_webhook_base_url_maps_wildcard_hosts_to_localhost(monkeypatch, host):
      +    monkeypatch.setattr(
      +        "hermes_cli.webhook._get_webhook_config",
      +        lambda: {"extra": {"host": host, "port": 9123}},
      +    )
      +    assert _get_webhook_base_url() == "http://localhost:9123"
      +
      +
      +def test_webhook_base_url_brackets_pinned_ipv6_host(monkeypatch):
      +    monkeypatch.setattr(
      +        "hermes_cli.webhook._get_webhook_config",
      +        lambda: {"extra": {"host": "::1", "port": 9123}},
      +    )
      +    assert _get_webhook_base_url() == "http://[::1]:9123"
      +
      +
       class TestSubscribe:
           def test_basic_create(self, capsys):
               webhook_command(_make_args(webhook_action="subscribe", name="test-hook"))
    • website/docs/guides/webhook-github-pr-review.md
      @@ -311,7 +311,6 @@ platforms:
         webhook:
           enabled: true
           extra:
      -      host: "0.0.0.0"         # bind address (default: 0.0.0.0)
             port: 8644               # listen port (default: 8644)
             secret: ""               # optional global fallback secret
             rate_limit: 30           # requests per minute per route
  • #65482 fix(webhook): scope reuse_address=False to macOS only

    merged to main

    files: gateway/platforms/webhook.py

2 PRs closed without merging

From the close comments: merged via another change 1 · won't fix / by design 1

  • #4267 security: bind SMS and Webhook adapters to 127.0.0.1 by default · won't fix / by design
  • #35206 [codex] fix(gateway): default webhook listener to loopback · merged via another change → #4260, #4267, #63711, #65482, #19745, #35206, #67950
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.33: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#19745, #63711, #65482
Closed issues
0
Attempts
2 PRs closed without merging
Fix link checked
16 Jul 2026

For your agent

The same facts, typed.