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