{"repo":"NousResearch/hermes-agent","public_id":"16394","display_label":"API key input sanitization and replacement handling","summary":"These issues and PRs address stripping ASCII control characters from API keys before saving, and adding a menu to replace or keep existing API keys during setup. Both concerns relate to validating and managing credential input in the CLI configuration flow.","subsystem":"hermes_cli","counts":{"members":10,"open_issues":2,"open_issues_distinct":2,"open_prs":3,"closed_issues":1,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":20162},"resolution":{"state":"partially_resolved","by":"pr-20162"},"freshness":{"state":"verified","last_verified_at":"2026-05-05T11:08:11+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17155555555555557},"fix_edges":[{"issue_number":16394,"pr_number":20162,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":16394,"title":"[Feature]: `hermes setup` should allow replacing an existing API key instead of silently skipping the prompt","url":null,"state":"closed"},{"kind":"issue","number":28158,"title":"[Bug/Security hardening]: API key Replace can persist composite pasted secrets into .env/auth.json without validation","url":null,"state":"open"},{"kind":"issue","number":55335,"title":"save_env_value can persist ASCII control characters in API keys","url":null,"state":"open"},{"kind":"pr","number":18355,"title":"fix(setup): add keep/replace/clear menu when API key already exists","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0ddc8aba6","number":0},{"kind":"pr","value":"#20162","number":20162},{"kind":"pr","value":"#17827","number":17827}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20162,"title":"fix(setup): offer Keep/Replace/Clear when API key already exists","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_prompt_api_key.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -3974,6 +3974,85 @@ def _model_flow_copilot_acp(config, current_model=\"\"):\n     print(f\"Default model set to: {selected} (via {pconfig.name})\")\n \n \n+def _prompt_api_key(pconfig, existing_key: str, provider_id: str = \"\") -> tuple:\n+    \"\"\"Shared API-key entry point for ``hermes setup`` / ``hermes model``.\n+\n+    Handles both first-time entry and the already-configured case.  When a key\n+    is already present, offers [K]eep / [R]eplace / [C]lear so the user can\n+    recover from a malformed paste without editing ``~/.hermes/.env`` by hand.\n+\n+    Returns ``(resolved_key, abort)``.  ``abort=True`` means the caller should\n+    ``return`` immediately — the user cancelled entry, declined to replace, or\n+    cleared the key and is now unconfigured.\n+    \"\"\"\n+    import getpass\n+\n+    from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER\n+    from hermes_cli.config import save_env_value\n+\n+    key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else \"\"\n+\n+    def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:\n+        if provider_id == \"lmstudio\" and allow_lmstudio_default:\n+            prompt = f\"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): \"\n+        else:\n+            prompt = f\"{key_env} (or Enter to cancel): \"\n+        try:\n+            entered = getpass.getpass(prompt).strip()\n+        except (KeyboardInterrupt, EOFError):\n+            print()\n+            return \"\"\n+        if not entered and provider_id == \"lmstudio\" and allow_lmstudio_default:\n+            return LMSTUDIO_NOAUTH_PLACEHOLDER\n+        return entered\n+\n+    # First-time entry ────────────────────────────────────────────────────\n+    if not existing_key:\n+        print(f\"No {pconfig.name} API key configured.\")\n+        if not key_env:\n+            return \"\", True\n+        new_key = _prompt_new_key(allow_lmstudio_default=True)\n+        if not new_key:\n+            print(\"Cancelled.\")\n+            return \"\", True\n+        save_env_value(key_env, new_key)\n+        print(\"API key saved.\")\n+        print()\n+        return new_key, False\n+\n+    # Already configured — offer K / R / C ────────────────────────────────\n+    print(f\"  {pconfig.name} API key: {existing_key[:8]}... ✓\")\n+    if not key_env:\n+        # Nothing we can rewrite; just acknowledge and move on.\n+        print()\n+        return existing_key, False\n+    try:\n+        choice = input(\"  [K]eep / [R]eplace / [C]lear (default K): \").strip().lower()\n+    except (KeyboardInterrupt, EOFError):\n+        print()\n+        choice = \"k\"\n+\n+    if choice.startswith(\"r\"):\n+        new_key = _prompt_new_key(allow_lmstudio_default=False)\n+        if not new_key:\n+            print(\"  No change.\")\n+            print()\n+            return existing_key, False\n+        save_env_value(key_env, new_key)\n+        print(\"  API key updated.\")\n+        print()\n+        return new_key, False\n+\n+    if choice.startswith(\"c\"):\n+        save_env_value(key_env, \"\")\n+        print(f\"  API key cleared.  Re-run `hermes setup` to configure {pconfig.name} again.\")\n+        return \"\", True\n+\n+    # Keep (default, or any other input)\n+    print()\n+    return existing_key, False\n+\n+\n def _model_flow_kimi(config, current_model=\"\"):\n     \"\"\"Kimi / Moonshot model selection with automatic endpoint routing.\n ","@@ -4008,26 +4087,9 @@ def _model_flow_kimi(config, current_model=\"\"):\n         if existing_key:\n             break\n \n-    if not existing_key:\n-        print(f\"No {pconfig.name} API key configured.\")\n-        if key_env:\n-            try:\n-                import getpass\n-\n-                new_key = getpass.getpass(f\"{key_env} (or Enter to cancel): \").strip()\n-            except (KeyboardInterrupt, EOFError):\n-                print()\n-                return\n-            if not new_key:\n-                print(\"Cancelled.\")\n-                return\n-            save_env_value(key_env, new_key)\n-            existing_key = new_key\n-            print(\"API key saved.\")\n-            print()\n-    else:\n-        print(f\"  {pconfig.name} API key: {existing_key[:8]}... ✓\")\n-        print()\n+    existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)\n+    if abort:\n+        return\n \n     # Step 2: Auto-detect endpoint from key prefix\n     is_coding_plan = existing_key.startswith(\"sk-kimi-\")","@@ -4128,25 +4190,9 @@ def _model_flow_stepfun(config, current_model=\"\"):\n         if existing_key:\n             break\n \n-    if not existing_key:\n-        print(f\"No {pconfig.name} API key configured.\")\n-        if key_env:\n-            try:\n-                import getpass\n-                new_key = getpass.getpass(f\"{key_env} (or Enter to cancel): \").strip()\n-            except (KeyboardInterrupt, EOFError):\n-                print()\n-                return\n-            if not new_key:\n-                print(\"Cancelled.\")\n-                return\n-            save_env_value(key_env, new_key)\n-            existing_key = new_key\n-            print(\"API key saved.\")\n-            print()\n-    else:\n-        print(f\"  {pconfig.name} API key: {existing_key[:8]}... ✓\")\n-        print()\n+    existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)\n+    if abort:\n+        return\n \n     current_base = \"\"\n     if base_url_env:","@@ -4522,33 +4568,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=\"\"):\n         if existing_key:\n             break\n \n-    if not existing_key:\n-        print(f\"No {pconfig.name} API key configured.\")\n-        if key_env:\n-            try:\n-                import getpass\n-\n-                if provider_id == \"lmstudio\":\n-                    prompt = f\"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): \"\n-                else:\n-                    prompt = f\"{key_env} (or Enter to cancel): \"\n-                new_key = getpass.getpass(prompt).strip()\n-            except (KeyboardInterrupt, EOFError):\n-                print()\n-                return\n-            if not new_key:\n-                if provider_id == \"lmstudio\":\n-                    new_key = LMSTUDIO_NOAUTH_PLACEHOLDER\n-                else:\n-                    print(\"Cancelled.\")\n-                    return\n-            save_env_value(key_env, new_key)\n-            existing_key = new_key\n-            print(\"API key saved.\")\n-            print()\n-    else:\n-        print(f\"  {pconfig.name} API key: {existing_key[:8]}... ✓\")\n-        print()\n+    existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)\n+    if abort:\n+        return\n \n     # Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash)\n     # are exhausted in a handful of agent turns, so refuse to wire up the"]},{"file":"tests/hermes_cli/test_prompt_api_key.py","hunks":["@@ -0,0 +1,157 @@\n+\"\"\"Tests for ``_prompt_api_key`` — the shared Keep/Replace/Clear menu used by\n+``hermes setup`` / ``hermes model`` when an API key already exists in ``.env``.\n+\n+Regression coverage for #16394: the wizard used to silently skip the key prompt\n+when any value was present (even malformed junk), leaving users stuck.\n+\"\"\"\n+from __future__ import annotations\n+\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n[redacted:email]\n+def profile_env(tmp_path, monkeypatch):\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    monkeypatch.setattr(Path, \"home\", lambda: tmp_path)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    (home / \".env\").write_text(\"\")\n+    return home\n+\n+\n+def _pconfig(name=\"deepseek\"):\n+    from hermes_cli.auth import PROVIDER_REGISTRY\n+    return PROVIDER_REGISTRY[name]\n+\n+\n+def _run_prompt(existing_key, choice, new_key=\"\", provider_id=\"\", pconfig_name=\"deepseek\"):\n+    \"\"\"Invoke _prompt_api_key with mocked input()/getpass() responses.\"\"\"\n+    from hermes_cli import main as m\n+\n+    pconfig = _pconfig(pconfig_name)\n+    with patch(\"builtins.input\", return_value=choice), \\\n+         patch(\"getpass.getpass\", return_value=new_key):\n+        return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id)\n+\n+\n+# First-time entry ────────────────────────────────────────────────────────────\n+\n+def test_first_time_save_new_key(profile_env):\n+    from hermes_cli.config import get_env_value\n+\n+    key, abort = _run_prompt(existing_key=\"\", choice=\"\", new_key=\"sk-abcdef\")\n+    assert key == \"sk-abcdef\"\n+    assert abort is False\n+    assert get_env_value(\"DEEPSEEK_API_KEY\") == \"sk-abcdef\"\n+\n+\n+def test_first_time_cancelled(profile_env):\n+    key, abort = _run_prompt(existing_key=\"\", choice=\"\", new_key=\"\")\n+    assert key == \"\"\n+    assert abort is True\n+\n+\n+# Already configured — K / R / C ───────────────────────────────────────────────\n+\n+def test_keep_default_empty_input(profile_env):\n+    from hermes_cli.config import save_env_value\n+    save_env_value(\"DEEPSEEK_API_KEY\", \"sk-existing\")\n+\n+    key, abort = _run_prompt(existing_key=\"sk-existing\", choice=\"\")\n+    assert key == \"sk-existing\"\n+    assert abort is False\n+\n+\n+def test_keep_letter_k(profile_env):\n+    key, abort = _run_prompt(existing_key=\"sk-existing\", choice=\"k\")\n+    assert key == \"sk-existing\"\n+    assert abort is False\n+\n+\n+def test_keep_on_unrecognised_input(profile_env):\n+    \"\"\"Garbage input falls through to keep — never destroys the user's key.\"\"\"\n+    key, abort = _run_prompt(existing_key=\"sk-existing\", choice=\"xyz\")\n+    assert key == \"sk-existing\"\n+    assert abort is False\n+\n+\n+def test_replace_saves_new_key(profile_env):\n+    from hermes_cli.config import get_env_value, save_env_value\n+    save_env_value(\"DEEPSEEK_API_KEY\", \"sk-malformed-junk\")\n+\n+    key, abort = _run_prompt(\n+        existing_key=\"sk-malformed-junk\", choice=\"r\", new_key=\"sk-fresh\"\n+    )\n+    assert key == \"sk-fresh\"\n+    assert abort is False\n+    assert get_env_value(\"DEEPSEEK_API_KEY\") == \"sk-fresh\"\n+\n+\n+def test_replace_cancelled_preserves_key(profile_env):\n+    \"\"\"Empty entry to the Replace prompt means cancel — keeps the old key intact.\"\"\"\n+    from hermes_cli.config import get_env_value, save_env_value\n+    save_env_value(\"DEEPSEEK_API_KEY\", \"sk-existing\")\n+\n+    key, abort = _run_prompt(\n+        existing_key=\"sk-existing\", choice=\"r\", new_key=\"\"\n+    )\n+    assert key == \"sk-existing\"\n+    assert abort is False\n+    assert get_env_value(\"DEEPSEEK_API_KEY\") == \"sk-existing\"\n+\n+\n+def test_clear_wipes_env_and_aborts(profile_env):\n+    from hermes_cli.config import get_env_value, save_env_value\n+    save_env_value(\"DEEPSEEK_API_KEY\", \"sk-existing\")\n+    save_env_value(\"OTHER_VAR\", \"keep-me\")\n+\n+    key, abort = _run_prompt(existing_key=\"sk-existing\", choice=\"c\")\n+    assert key == \"\"\n+    assert abort is True\n+    # Cleared, but sibling entries untouched.\n+    assert not get_env_value(\"DEEPSEEK_API_KEY\")\n+    assert get_env_value(\"OTHER_VAR\") == \"keep-me\"\n+\n+\n+def test_ctrl_c_at_choice_prompt_keeps(profile_env):\n+    from hermes_cli import main as m\n+\n+    pconfig = _pconfig(\"deepseek\")\n+    with patch(\"builtins.input\", side_effect=KeyboardInterrupt):\n+        key, abort = m._prompt_api_key(pconfig, \"sk-existing\")\n+    assert key == \"sk-existing\"\n+    assert abort is False\n+\n+\n+# LM Studio no-auth placeholder ────────────────────────────────────────────────\n+\n+def test_lmstudio_first_time_empty_uses_placeholder(profile_env):\n+    from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER\n+    from hermes_cli.config import get_env_value\n+\n+    key, abort = _run_prompt(\n+        existing_key=\"\", choice=\"\", new_key=\"\",\n+        provider_id=\"lmstudio\", pconfig_name=\"lmstudio\",\n+    )\n+    assert key == LMSTUDIO_NOAUTH_PLACEHOLDER\n+    assert abort is False\n+    assert get_env_value(\"LM_API_KEY\") == LMSTUDIO_NOAUTH_PLACEHOLDER\n+\n+\n+def test_lmstudio_replace_empty_does_not_overwrite_with_placeholder(profile_env):\n+    \"\"\"On REPLACE with empty input, preserve the user's existing key — do NOT\n+    silently substitute the placeholder.  The placeholder path only fires for\n+    first-time configuration where the user has made no explicit choice yet.\"\"\"\n+    from hermes_cli.config import get_env_value, save_env_value\n+    save_env_value(\"LM_API_KEY\", \"my-real-lmstudio-key\")\n+\n+    key, abort = _run_prompt(\n+        existing_key=\"my-real-lmstudio-key\", choice=\"r\", new_key=\"\",\n+        provider_id=\"lmstudio\", pconfig_name=\"lmstudio\",\n+    )\n+    assert key == \"my-real-lmstudio-key\"\n+    assert abort is False\n+    assert get_env_value(\"LM_API_KEY\") == \"my-real-lmstudio-key\"\n"]}]},{"kind":"pr","number":28281,"title":"fix(auth): reject malformed API key pastes","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#28158","number":28158}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55336,"title":"fix(config): strip control chars from saved env credentials","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55406,"title":"fix: strip ASCII control characters from API keys before saving (#55335)","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":55767,"title":"fix(cli): strip ASCII control characters in save_env_value","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#55336","number":55336}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":58074,"title":"fix(config): strip control chars from saved credentials","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55336","number":55336}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}