{"repo":"NousResearch/hermes-agent","public_id":"10195","display_label":"Pairing lockout bypass security fix","summary":"A security vulnerability allows the pairing lockout to be bypassed by calling approve_code() directly. These members address enforcing the lockout check within approve_code() to prevent brute-force attacks.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21325,"merged_at":"2026-05-07T14:18:21+00:00"},"resolution":{"state":"resolved","by":"pr-21325"},"freshness":{"state":"verified","last_verified_at":"2026-05-07T14:18:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":10195,"pr_number":21325,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-07T14:18:21+00:00"}],"members":[{"kind":"issue","number":10195,"title":"Security: Pairing lockout can be bypassed by approving a valid code during lockout","url":null,"state":"closed"},{"kind":"pr","number":10248,"title":"fix(pairing): enforce lockout check in approve_code() to prevent brute-force bypass","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":21325,"title":"fix(pairing): enforce lockout on approve_code, not just generate_code (#10195)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/pairing.py","hermes_cli/pairing.py","tests/gateway/test_pairing.py"],"fix_hunks":[{"file":"gateway/pairing.py","hunks":["@@ -195,12 +195,23 @@ def approve_code(self, platform: str, code: str) -> Optional[dict]:\n         \"\"\"\n         Approve a pairing code. Adds the user to the approved list.\n \n-        Returns {user_id, user_name} on success, None if code is invalid/expired.\n+        Returns {user_id, user_name} on success, None if code is\n+        invalid/expired OR the platform is currently locked out after\n+        ``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can\n+        disambiguate with ``_is_locked_out(platform)``.\n         \"\"\"\n         with self._lock:\n             self._cleanup_expired(platform)\n             code = code.upper().strip()\n \n+            # Lockout check — must run before the pending lookup so a\n+            # valid code (e.g. one already sitting in pending) cannot be\n+            # accepted once the lockout fires. Without this, the lockout\n+            # only blocks `generate_code`, not `approve_code` — nullifying\n+            # the brute-force protection for any code already issued.\n+            if self._is_locked_out(platform):\n+                return None\n+\n             pending = self._load_json(self._pending_path(platform))\n             if code not in pending:\n                 self._record_failed_attempt(platform)"]},{"file":"hermes_cli/pairing.py","hunks":["@@ -73,6 +73,24 @@ def _cmd_approve(store, platform: str, code: str):\n         display = f\"{name} ({uid})\" if name else uid\n         print(f\"\\n  Approved! User {display} on {platform} can now use the bot~\")\n         print(\"  They'll be recognized automatically on their next message.\\n\")\n+    elif store._is_locked_out(platform):\n+        # Disambiguate: approve_code returns None for both invalid codes\n+        # and lockout. Tell the operator it's lockout so they don't chase\n+        # a \"wrong code\" rabbit hole (#10195).\n+        import time as _time\n+        limits = store._load_json(store._rate_limit_path())\n+        lockout_until = limits.get(f\"_lockout:{platform}\", 0)\n+        remaining = max(0, int(lockout_until - _time.time()))\n+        mins = remaining // 60\n+        print(\n+            f\"\\n  Platform '{platform}' is locked out after too many failed \"\n+            f\"approval attempts.\"\n+        )\n+        print(f\"  Lockout clears in ~{mins} minute(s).\")\n+        print(\n+            \"  To reset sooner, delete the '_lockout:{0}' entry from \"\n+            \"~/.hermes/platforms/pairing/_rate_limits.json\\n\".format(platform)\n+        )\n     else:\n         print(f\"\\n  Code '{code}' not found or expired for platform '{platform}'.\")\n         print(\"  Run 'hermes pairing list' to see pending codes.\\n\")"]},{"file":"tests/gateway/test_pairing.py","hunks":["@@ -238,6 +238,42 @@ def test_lockout_blocks_code_generation(self, tmp_path):\n             code = store.generate_code(\"telegram\", \"newuser\")\n         assert code is None\n \n+    def test_lockout_blocks_code_approval(self, tmp_path):\n+        \"\"\"Regression guard for #10195: lockout must also gate approve_code.\n+\n+        Prior to the fix, 5 failed approvals set the lockout flag but\n+        approve_code() never consulted it — so any valid code already\n+        in `pending` (or a later lucky guess) still got accepted,\n+        nullifying the brute-force protection.\n+        \"\"\"\n+        with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n+            store = PairingStore()\n+            # Generate a valid code before triggering the lockout.\n+            valid_code = store.generate_code(\"telegram\", \"attacker\", \"Attacker\")\n+            assert valid_code is not None\n+\n+            # Trigger the lockout with wrong codes.\n+            for _ in range(MAX_FAILED_ATTEMPTS):\n+                assert store.approve_code(\"telegram\", \"WRONGCODE\") is None\n+            assert store._is_locked_out(\"telegram\") is True\n+\n+            # The valid code must be rejected while the lockout is active,\n+            # and the user must NOT land in the approved list.\n+            result = store.approve_code(\"telegram\", valid_code)\n+            assert result is None\n+            assert store.is_approved(\"telegram\", \"attacker\") is False\n+\n+            # Simulate lockout expiry — the valid code is still in pending\n+            # (we didn't pop it) and must now approve normally.\n+            limits = store._load_json(store._rate_limit_path())\n+            limits[\"_lockout:telegram\"] = time.time() - 1\n+            store._save_json(store._rate_limit_path(), limits)\n+\n+            result = store.approve_code(\"telegram\", valid_code)\n+            assert result is not None\n+            assert result[\"user_id\"] == \"attacker\"\n+            assert store.is_approved(\"telegram\", \"attacker\") is True\n+\n     def test_lockout_expires(self, tmp_path):\n         with patch(\"gateway.pairing.PAIRING_DIR\", tmp_path):\n             store = PairingStore()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}