# NousResearch/hermes-agent · API key input sanitization and replacement handling

## Open now

- 2 issues, 3 PRs open on GitHub.
- #28158: \[Bug/Security hardening\]: API key Replace can persist composite pasted secrets into .env/auth.json without validation
- #55335: save_env_value can persist ASCII control characters in API keys
- PR #28281: fix(auth): reject malformed API key pastes
- PR #55336: fix(config): strip control chars from saved env credentials
- PR #55767: fix(cli): strip ASCII control characters in save_env_value

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.

## TL;DR

- 10 member(s): 2 open issue(s), 1 closed issue(s), 3 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #20162. Verify it addresses your case.
- **Recommended:** read PR #20162 (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: `partially_resolved` · deciding PR(s): `pr-20162`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.17`: 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

- #28158: \[Bug/Security hardening\]: API key Replace can persist composite pasted secrets into .env/auth.json without validation
- #55335: save_env_value can persist ASCII control characters in API keys

## Closed / duplicate issues

- #16394: \[Feature\]: \`hermes setup\` should allow replacing an existing API key instead of silently skipping the prompt

## Prior & competing PRs

- **#20162: fix(setup): offer Keep/Replace/Clear when API key already exists**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `tests/hermes_cli/test_prompt_api_key.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -3974,6 +3974,85 @@ def _model_flow_copilot_acp(config, current_model=""):
         print(f"Default model set to: {selected} (via {pconfig.name})")
     
     
    +def _prompt_api_key(pconfig, existing_key: str, provider_id: str = "") -> tuple:
    +    """Shared API-key entry point for ``hermes setup`` / ``hermes model``.
    +
    +    Handles both first-time entry and the already-configured case.  When a key
    +    is already present, offers [K]eep / [R]eplace / [C]lear so the user can
    +    recover from a malformed paste without editing ``~/.hermes/.env`` by hand.
    +
    +    Returns ``(resolved_key, abort)``.  ``abort=True`` means the caller should
    +    ``return`` immediately — the user cancelled entry, declined to replace, or
    +    cleared the key and is now unconfigured.
    +    """
    +    import getpass
    +
    +    from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
    +    from hermes_cli.config import save_env_value
    +
    +    key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else ""
    +
    +    def _prompt_new_key(*, allow_lmstudio_default: bool) -> str:
    +        if provider_id == "lmstudio" and allow_lmstudio_default:
    +            prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
    +        else:
    +            prompt = f"{key_env} (or Enter to cancel): "
    +        try:
    +            entered = getpass.getpass(prompt).strip()
    +        except (KeyboardInterrupt, EOFError):
    +            print()
    +            return ""
    +        if not entered and provider_id == "lmstudio" and allow_lmstudio_default:
    +            return LMSTUDIO_NOAUTH_PLACEHOLDER
    +        return entered
    +
    +    # First-time entry ────────────────────────────────────────────────────
    +    if not existing_key:
    +        print(f"No {pconfig.name} API key configured.")
    +        if not key_env:
    +            return "", True
    +        new_key = _prompt_new_key(allow_lmstudio_default=True)
    +        if not new_key:
    +            print("Cancelled.")
    +            return "", True
    +        save_env_value(key_env, new_key)
    +        print("API key saved.")
    +        print()
    +        return new_key, False
    +
    +    # Already configured — offer K / R / C ────────────────────────────────
    +    print(f"  {pconfig.name} API key: {existing_key[:8]}... ✓")
    +    if not key_env:
    +        # Nothing we can rewrite; just acknowledge and move on.
    +        print()
    +        return existing_key, False
    +    try:
    +        choice = input("  [K]eep / [R]eplace / [C]lear (default K): ").strip().lower()
    +    except (KeyboardInterrupt, EOFError):
    +        print()
    +        choice = "k"
    +
    +    if choice.startswith("r"):
    +        new_key = _prompt_new_key(allow_lmstudio_default=False)
    +        if not new_key:
    +            print("  No change.")
    +            print()
    +            return existing_key, False
    +        save_env_value(key_env, new_key)
    +        print("  API key updated.")
    +        print()
    +        return new_key, False
    +
    +    if choice.startswith("c"):
    +        save_env_value(key_env, "")
    +        print(f"  API key cleared.  Re-run `hermes setup` to configure {pconfig.name} again.")
    +        return "", True
    +
    +    # Keep (default, or any other input)
    +    print()
    +    return existing_key, False
    +
    +
     def _model_flow_kimi(config, current_model=""):
         """Kimi / Moonshot model selection with automatic endpoint routing.
     
    ```

    ```diff
    @@ -4008,26 +4087,9 @@ def _model_flow_kimi(config, current_model=""):
             if existing_key:
                 break
     
    -    if not existing_key:
    -        print(f"No {pconfig.name} API key configured.")
    -        if key_env:
    -            try:
    -                import getpass
    -
    -                new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
    -            except (KeyboardInterrupt, EOFError):
    -                print()
    -                return
    -            if not new_key:
    -                print("Cancelled.")
    -                return
    -            save_env_value(key_env, new_key)
    -            existing_key = new_key
    -            print("API key saved.")
    -            print()
    -    else:
    -        print(f"  {pconfig.name} API key: {existing_key[:8]}... ✓")
    -        print()
    +    existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
    +    if abort:
    +        return
     
         # Step 2: Auto-detect endpoint from key prefix
         is_coding_plan = existing_key.startswith("sk-kimi-")
    ```

    ```diff
    @@ -4128,25 +4190,9 @@ def _model_flow_stepfun(config, current_model=""):
             if existing_key:
                 break
     
    -    if not existing_key:
    -        print(f"No {pconfig.name} API key configured.")
    -        if key_env:
    -            try:
    -                import getpass
    -                new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
    -            except (KeyboardInterrupt, EOFError):
    -                print()
    -                return
    -            if not new_key:
    -                print("Cancelled.")
    -                return
    -            save_env_value(key_env, new_key)
    -            existing_key = new_key
    -            print("API key saved.")
    -            print()
    -    else:
    -        print(f"  {pconfig.name} API key: {existing_key[:8]}... ✓")
    -        print()
    +    existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
    +    if abort:
    +        return
     
         current_base = ""
         if base_url_env:
    ```

    ```diff
    @@ -4522,33 +4568,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
             if existing_key:
                 break
     
    -    if not existing_key:
    -        print(f"No {pconfig.name} API key configured.")
    -        if key_env:
    -            try:
    -                import getpass
    -
    -                if provider_id == "lmstudio":
    -                    prompt = f"{key_env} (Enter for no-auth default {LMSTUDIO_NOAUTH_PLACEHOLDER!r}): "
    -                else:
    -                    prompt = f"{key_env} (or Enter to cancel): "
    -                new_key = getpass.getpass(prompt).strip()
    -            except (KeyboardInterrupt, EOFError):
    -                print()
    -                return
    -            if not new_key:
    -                if provider_id == "lmstudio":
    -                    new_key = LMSTUDIO_NOAUTH_PLACEHOLDER
    -                else:
    -                    print("Cancelled.")
    -                    return
    -            save_env_value(key_env, new_key)
    -            existing_key = new_key
    -            print("API key saved.")
    -            print()
    -    else:
    -        print(f"  {pconfig.name} API key: {existing_key[:8]}... ✓")
    -        print()
    +    existing_key, abort = _prompt_api_key(pconfig, existing_key, provider_id=provider_id)
    +    if abort:
    +        return
     
         # Gemini free-tier gate: free-tier daily quotas (<= 250 RPD for Flash)
         # are exhausted in a handful of agent turns, so refuse to wire up the
    ```
  - `tests/hermes_cli/test_prompt_api_key.py`

    ```diff
    @@ -0,0 +1,157 @@
    +"""Tests for ``_prompt_api_key`` — the shared Keep/Replace/Clear menu used by
    +``hermes setup`` / ``hermes model`` when an API key already exists in ``.env``.
    +
    +Regression coverage for #16394: the wizard used to silently skip the key prompt
    +when any value was present (even malformed junk), leaving users stuck.
    +"""
    +from __future__ import annotations
    +
    +from pathlib import Path
    +from unittest.mock import patch
    +
    +import pytest
    +
    +
    [redacted:email]
    +def profile_env(tmp_path, monkeypatch):
    +    home = tmp_path / ".hermes"
    +    home.mkdir()
    +    monkeypatch.setattr(Path, "home", lambda: tmp_path)
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    (home / ".env").write_text("")
    +    return home
    +
    +
    +def _pconfig(name="deepseek"):
    +    from hermes_cli.auth import PROVIDER_REGISTRY
    +    return PROVIDER_REGISTRY[name]
    +
    +
    +def _run_prompt(existing_key, choice, new_key="", provider_id="", pconfig_name="deepseek"):
    +    """Invoke _prompt_api_key with mocked input()/getpass() responses."""
    +    from hermes_cli import main as m
    +
    +    pconfig = _pconfig(pconfig_name)
    +    with patch("builtins.input", return_value=choice), \
    +         patch("getpass.getpass", return_value=new_key):
    +        return m._prompt_api_key(pconfig, existing_key, provider_id=provider_id)
    +
    +
    +# First-time entry ────────────────────────────────────────────────────────────
    +
    +def test_first_time_save_new_key(profile_env):
    +    from hermes_cli.config import get_env_value
    +
    +    key, abort = _run_prompt(existing_key="", choice="", new_key="sk-abcdef")
    +    assert key == "sk-abcdef"
    +    assert abort is False
    +    assert get_env_value("DEEPSEEK_API_KEY") == "sk-abcdef"
    +
    +
    +def test_first_time_cancelled(profile_env):
    +    key, abort = _run_prompt(existing_key="", choice="", new_key="")
    +    assert key == ""
    +    assert abort is True
    +
    +
    +# Already configured — K / R / C ───────────────────────────────────────────────
    +
    +def test_keep_default_empty_input(profile_env):
    +    from hermes_cli.config import save_env_value
    +    save_env_value("DEEPSEEK_API_KEY", "sk-existing")
    +
    +    key, abort = _run_prompt(existing_key="sk-existing", choice="")
    +    assert key == "sk-existing"
    +    assert abort is False
    +
    +
    +def test_keep_letter_k(profile_env):
    +    key, abort = _run_prompt(existing_key="sk-existing", choice="k")
    +    assert key == "sk-existing"
    +    assert abort is False
    +
    +
    +def test_keep_on_unrecognised_input(profile_env):
    +    """Garbage input falls through to keep — never destroys the user's key."""
    +    key, abort = _run_prompt(existing_key="sk-existing", choice="xyz")
    +    assert key == "sk-existing"
    +    assert abort is False
    +
    +
    +def test_replace_saves_new_key(profile_env):
    +    from hermes_cli.config import get_env_value, save_env_value
    +    save_env_value("DEEPSEEK_API_KEY", "sk-malformed-junk")
    +
    +    key, abort = _run_prompt(
    +        existing_key="sk-malformed-junk", choice="r", new_key="sk-fresh"
    +    )
    +    assert key == "sk-fresh"
    +    assert abort is False
    +    assert get_env_value("DEEPSEEK_API_KEY") == "sk-fresh"
    +
    +
    +def test_replace_cancelled_preserves_key(profile_env):
    +    """Empty entry to the Replace prompt means cancel — keeps the old key intact."""
    +    from hermes_cli.config import get_env_value, save_env_value
    +    save_env_value("DEEPSEEK_API_KEY", "sk-existing")
    +
    +    key, abort = _run_prompt(
    +        existing_key="sk-existing", choice="r", new_key=""
    +    )
    +    assert key == "sk-existing"
    +    assert abort is False
    +    assert get_env_value("DEEPSEEK_API_KEY") == "sk-existing"
    +
    +
    +def test_clear_wipes_env_and_aborts(profile_env):
    +    from hermes_cli.config import get_env_value, save_env_value
    +    save_env_value("DEEPSEEK_API_KEY", "sk-existing")
    +    save_env_value("OTHER_VAR", "keep-me")
    +
    +    key, abort = _run_prompt(existing_key="sk-existing", choice="c")
    +    assert key == ""
    +    assert abort is True
    +    # Cleared, but sibling entries untouched.
    +    assert not get_env_value("DEEPSEEK_API_KEY")
    +    assert get_env_value("OTHER_VAR") == "keep-me"
    +
    +
    +def test_ctrl_c_at_choice_prompt_keeps(profile_env):
    +    from hermes_cli import main as m
    +
    +    pconfig = _pconfig("deepseek")
    +    with patch("builtins.input", side_effect=KeyboardInterrupt):
    +        key, abort = m._prompt_api_key(pconfig, "sk-existing")
    +    assert key == "sk-existing"
    +    assert abort is False
    +
    +
    +# LM Studio no-auth placeholder ────────────────────────────────────────────────
    +
    +def test_lmstudio_first_time_empty_uses_placeholder(profile_env):
    +    from hermes_cli.auth import LMSTUDIO_NOAUTH_PLACEHOLDER
    +    from hermes_cli.config import get_env_value
    +
    +    key, abort = _run_prompt(
    +        existing_key="", choice="", new_key="",
    +        provider_id="lmstudio", pconfig_name="lmstudio",
    +    )
    +    assert key == LMSTUDIO_NOAUTH_PLACEHOLDER
    +    assert abort is False
    +    assert get_env_value("LM_API_KEY") == LMSTUDIO_NOAUTH_PLACEHOLDER
    +
    +
    +def test_lmstudio_replace_empty_does_not_overwrite_with_placeholder(profile_env):
    +    """On REPLACE with empty input, preserve the user's existing key — do NOT
    +    silently substitute the placeholder.  The placeholder path only fires for
    +    first-time configuration where the user has made no explicit choice yet."""
    +    from hermes_cli.config import get_env_value, save_env_value
    +    save_env_value("LM_API_KEY", "my-real-lmstudio-key")
    +
    +    key, abort = _run_prompt(
    +        existing_key="my-real-lmstudio-key", choice="r", new_key="",
    +        provider_id="lmstudio", pconfig_name="lmstudio",
    +    )
    +    assert key == "my-real-lmstudio-key"
    +    assert abort is False
    +    assert get_env_value("LM_API_KEY") == "my-real-lmstudio-key"
    ```
- **#55406: fix: strip ASCII control characters from API keys before saving (#55335)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#18355: fix(setup): add keep/replace/clear menu when API key already exists**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `0ddc8aba6`, `#20162`, `#17827`
- **#28281: fix(auth): reject malformed API key pastes**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#28158`
  - files: `hermes_cli/config.py`, `hermes_cli/main.py`, `hermes_cli/setup.py`, `tests/hermes_cli/test_config.py`, `tests/hermes_cli/test_prompt_api_key.py`
- **#55336: fix(config): strip control chars from saved env credentials**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `hermes_cli/config.py`, `hermes_cli/onepassword_secrets_cli.py`, `hermes_cli/secrets_cli.py`, `tests/hermes_cli/test_config.py`
- **#55767: fix(cli): strip ASCII control characters in save_env_value**. open
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#55336`
  - files: `hermes_cli/config.py`, `tests/hermes_cli/test_config.py`
- **#58074: fix(config): strip control chars from saved credentials**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#55336`

## Fix status

- Merged to main in PR #20162 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-05`.
- Subsystem: `hermes_cli`
