# NousResearch/hermes-agent · Adapter bind address and security hardening fixes

## Open now

- 1 issue, 0 PRs open on GitHub.
- #4260: Security: SMS and Webhook adapters bind to 0.0.0.0 by default

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.

## TL;DR

- 6 member(s): 1 open issue(s), 0 closed issue(s), 0 open PR(s), 3 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #65482. Verify it addresses your case.
- **Recommended:** read PR #65482 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.33`: the share of member 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.

## Open related issues

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

## Prior & competing PRs

- **#19745: fix(sms): mark missing-config errors as non-retryable; default bind to 127.0.0.1**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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 · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `gateway/platforms/webhook.py`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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", {})
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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}"
     
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -8,6 +8,7 @@
     
     from hermes_cli.webhook import (
         webhook_command,
    +    _get_webhook_base_url,
         _load_subscriptions,
         _save_subscriptions,
         _subscriptions_path,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/webhook.py`
- **#4267: security: bind SMS and Webhook adapters to 127.0.0.1 by default**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`
- **#35206: \[codex\] fix(gateway): default webhook listener to loopback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#4260`, `#4267`, `#63711`, `#65482`, `#19745`, `#35206`, `#67950`

## Fix status

- Merged to main in PR #65482 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-16`.
- Subsystem: `gateway`
