{"repo":"NousResearch/hermes-agent","public_id":"52777","display_label":"Credential pool checkout ignoring provider boundaries","summary":"One bug report describes credential pool checkout as provider-blind, leading to cross-provider 403 errors. Three pull requests with similar titles propose gating or enforcing credential pools by provider.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63048,"merged_at":"2026-07-12T10:00:54+00:00"},"resolution":{"state":"resolved","by":"pr-63048"},"freshness":{"state":"verified","last_verified_at":"2026-07-12T10:00:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":52777,"pr_number":63048,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-12T10:00:54+00:00"}],"members":[{"kind":"issue","number":52777,"title":"Bug: Credential pool checkout is provider-blind — causes cross-provider 403s","url":null,"state":"closed"},{"kind":"pr","number":52799,"title":"fix: gate credential pools by provider","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":55703,"title":"fix(agent): gate credential pools by provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52799","number":52799},{"kind":"pr","value":"#52777","number":52777}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":63048,"title":"fix(auth): enforce credential pool provider boundaries","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/credential_pool.py","hermes_cli/runtime_provider.py","tests/agent/test_credential_pool_provider_boundary.py"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -412,13 +412,25 @@ def init_agent(\n     agent.skip_context_files = skip_context_files\n     agent.load_soul_identity = load_soul_identity\n     agent.pass_session_id = pass_session_id\n-    agent._credential_pool = [redacted:secret-assignment]\n     agent.log_prefix_chars = log_prefix_chars\n     agent.log_prefix = f\"{log_prefix} \" if log_prefix else \"\"\n     # Store effective base URL for feature detection (prompt caching, reasoning, etc.)\n     agent.base_url = base_url or \"\"\n     provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None\n     agent.provider = provider_name or \"\"\n+    if credential_pool is not None:\n+        try:\n+            from agent.credential_pool import credential_pool_matches_provider\n+\n+            if not credential_pool_matches_provider(\n+                credential_pool,\n+                agent.provider,\n+                base_url=agent.base_url,\n+            ):\n+                credential_pool = None\n+        except Exception:\n+            credential_pool = None\n+    agent._credential_pool = [redacted:secret-assignment]\n     agent.acp_command = acp_command or command\n     agent.acp_args = list(acp_args or args or [])\n     if api_mode in {\"chat_completions\", \"codex_responses\", \"anthropic_messages\", \"bedrock_converse\", \"codex_app_server\"}:"]},{"file":"agent/credential_pool.py","hunks":["@@ -445,6 +445,44 @@ def get_pool_strategy(provider: str) -> str:\n     return STRATEGY_FILL_FIRST\n \n \n+def credential_pool_matches_provider(\n+    pool_or_provider: Any,\n+    provider: Optional[str],\n+    *,\n+    base_url: Optional[str] = None,\n+) -> bool:\n+    \"\"\"Return whether a pool belongs to the requested runtime provider.\n+\n+    Named custom endpoints intentionally use two identities: the live agent is\n+    ``custom`` while its pool is keyed ``custom:<name>``. Accept that pair only\n+    when the runtime base URL resolves to the exact same custom pool key.\n+    Empty string identities fail closed. Legacy pool adapters without a\n+    ``provider`` attribute remain compatible; production pools are scoped.\n+    \"\"\"\n+    raw_pool_provider = getattr(pool_or_provider, \"provider\", None)\n+    if raw_pool_provider is None:\n+        if isinstance(pool_or_provider, str):\n+            raw_pool_provider = pool_or_provider\n+        else:\n+            # Backward compatibility for lightweight/unscoped pool adapters.\n+            # Production CredentialPool instances always carry ``provider``;\n+            # old plugins and tests may expose only select()/has_credentials().\n+            return True\n+    pool_provider = str(raw_pool_provider or \"\").strip().lower()\n+    provider_norm = str(provider or \"\").strip().lower()\n+    if not pool_provider or not provider_norm:\n+        return False\n+    if pool_provider == provider_norm:\n+        return True\n+    if provider_norm != \"custom\" or not pool_provider.startswith(CUSTOM_POOL_PREFIX):\n+        return False\n+    try:\n+        matched_pool = get_custom_provider_pool_key(base_url or \"\")\n+    except Exception:\n+        return False\n+    return str(matched_pool or \"\").strip().lower() == pool_provider\n+\n+\n DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL = 1\n \n "]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -11,7 +11,13 @@\n logger = logging.getLogger(__name__)\n \n from hermes_cli import auth as auth_mod\n-from agent.credential_pool import CredentialPool, PooledCredential, get_custom_provider_pool_key, load_pool\n+from agent.credential_pool import (\n+    CredentialPool,\n+    PooledCredential,\n+    credential_pool_matches_provider,\n+    get_custom_provider_pool_key,\n+    load_pool,\n+)\n from agent.secret_scope import get_secret as _get_secret\n from hermes_cli.auth import (\n     AuthError,","@@ -1731,7 +1737,19 @@ def resolve_runtime_provider(\n                 if not pool_api_key or not _agent_key_is_usable(nous_state, min_ttl):\n                     logger.debug(\"Nous pool entry agent_key still unavailable, falling through to runtime resolution\")\n                     pool_api_key = \"\"\n-        if entry is not None and pool_api_key:\n+        if (\n+            entry is not None\n+            and pool_api_key\n+            and credential_pool_matches_provider(\n+                pool,\n+                provider,\n+                base_url=(\n+                    getattr(entry, \"runtime_base_url\", None)\n+                    or getattr(entry, \"base_url\", None)\n+                    or \"\"\n+                ),\n+            )\n+        ):\n             return _resolve_runtime_from_pool_entry(\n                 provider=provider,\n                 entry=entry,"]},{"file":"tests/agent/test_credential_pool_provider_boundary.py","hunks":["@@ -0,0 +1,64 @@\n+\"\"\"Credential pools must never cross provider or custom-endpoint boundaries.\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+from agent.credential_pool import credential_pool_matches_provider\n+from hermes_cli import runtime_provider as rp\n+\n+\n+def test_provider_match_requires_exact_non_custom_identity():\n+    assert credential_pool_matches_provider(\"deepseek\", \"deepseek\")\n+    assert not credential_pool_matches_provider(\"openai-codex\", \"deepseek\")\n+    assert not credential_pool_matches_provider(\"\", \"deepseek\")\n+\n+\n+def test_custom_pool_match_is_scoped_by_endpoint():\n+    with patch(\n+        \"agent.credential_pool.get_custom_provider_pool_key\",\n+        return_value=\"custom:lab\",\n+    ):\n+        assert credential_pool_matches_provider(\n+            \"custom:lab\", \"custom\", base_url=\"https://lab.example/v1\"\n+        )\n+        assert not credential_pool_matches_provider(\n+            \"custom:other\", \"custom\", base_url=\"https://lab.example/v1\"\n+        )\n+\n+\n+def test_runtime_ignores_pool_loaded_for_different_provider(monkeypatch):\n+    entry = SimpleNamespace(\n+        provider=\"openai-codex\",\n+        access_token=\"[redacted:secret-assignment]\",\n+        runtime_api_key=\"[redacted:secret-assignment]\",\n+        runtime_base_url=\"https://chatgpt.com/backend-api/codex\",\n+        base_url=\"https://chatgpt.com/backend-api/codex\",\n+    )\n+    pool = SimpleNamespace(\n+        provider=\"openai-codex\",\n+        has_credentials=[redacted:secret-assignment] True,\n+        select=lambda: entry,\n+    )\n+    monkeypatch.setattr(rp, \"load_pool\", lambda _provider: pool)\n+    monkeypatch.setattr(rp, \"resolve_provider\", lambda *_a, **_kw: \"deepseek\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\"provider\": \"deepseek\", \"default\": \"deepseek-chat\"},\n+    )\n+    monkeypatch.setattr(\n+        rp,\n+        \"resolve_api_key_provider_credentials\",\n+        lambda _provider: {\n+            \"provider\": \"deepseek\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+            \"base_url\": \"https://api.deepseek.com/v1\",\n+            \"source\": \"env\",\n+        },\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"deepseek\")\n+\n+    assert resolved[\"provider\"] == \"deepseek\"\n+    assert resolved[\"api_key\"] == \"deepseek-key\"\n+    assert resolved[\"base_url\"] == \"https://api.deepseek.com/v1\"\n\\ No newline at end of file\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}