{"repo":"NousResearch/hermes-agent","public_id":"15803","display_label":"Stop env-sourced API keys from being stored","summary":"These issues and pull requests address a bug where API keys sourced from environment variables were being written back into stored provider configuration. Related work includes preserving references to custom provider API key sources.","subsystem":null,"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":16372,"merged_at":"2026-04-27T04:52:13+00:00"},"resolution":{"state":"resolved","by":"pr-16372"},"freshness":{"state":"stale","last_verified_at":"2026-04-27T04:52:13+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":15803,"pr_number":16372,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-27T04:52:13+00:00"}],"members":[{"kind":"issue","number":15803,"title":"fix(model): avoid persisting key_env-resolved secrets","url":null,"state":"closed"},{"kind":"pr","number":15805,"title":"fix(model): avoid persisting key_env-resolved secrets","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#16372","number":16372},{"kind":"pr","value":"#15803","number":15803}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16372,"title":"fix(model): avoid persisting key_env-resolved secrets to providers entry","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_custom_provider_model_switch.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -3332,7 +3332,26 @@ def _model_flow_named_custom(config, provider_info):\n             provider_entry = providers_cfg.get(provider_key)\n             if isinstance(provider_entry, dict):\n                 provider_entry[\"default_model\"] = model_name\n-                if config_api_key and not str(provider_entry.get(\"api_key\", \"\") or \"\").strip():\n+                # Only persist an inline api_key when the user originally had\n+                # one (either a literal secret or a ``${VAR}`` template). When\n+                # the entry relies on ``key_env``, do not synthesize a\n+                # ``${key_env}`` api_key — the runtime already resolves the\n+                # key from ``key_env`` directly, and writing the resolved\n+                # secret (or even a synthesized template) would silently\n+                # downgrade credential hygiene on entries that intentionally\n+                # keep plaintext out of ``config.yaml``. See issue #15803.\n+                original_api_key_ref = str(\n+                    provider_info.get(\"api_key_ref\", \"\") or \"\"\n+                ).strip()\n+                original_api_key = str(\n+                    provider_info.get(\"api_key\", \"\") or \"\"\n+                ).strip()\n+                had_inline_api_key = [redacted:secret-assignment] or original_api_key)\n+                if (\n+                    had_inline_api_key\n+                    and config_api_key\n+                    and not str(provider_entry.get(\"api_key\", \"\") or \"\").strip()\n+                ):\n                     provider_entry[\"api_key\"] = config_api_key\n                 if key_env and not str(provider_entry.get(\"key_env\", \"\") or \"\").strip():\n                     provider_entry[\"key_env\"] = key_env"]},{"file":"tests/hermes_cli/test_custom_provider_model_switch.py","hunks":["@@ -322,3 +322,129 @@ def _pick_neuralwatt(labels, default=0):\n         assert config[\"model\"][\"api_key\"] == \"${NEURALWATT_API_KEY}\"\n         assert config[\"custom_providers\"][0][\"api_key\"] == \"${NEURALWATT_API_KEY}\"\n         assert \"[redacted:openai-key]\" not in saved\n+\n+    def test_key_env_providers_dict_entry_does_not_add_api_key(\n+        self, config_home, monkeypatch\n+    ):\n+        \"\"\"Regression for #15803: a ``providers:`` (keyed-schema) entry that\n+        relies on ``key_env`` must not gain an ``api_key`` field after the\n+        model picker runs.\n+\n+        Before the fix, ``_model_flow_named_custom`` synthesized\n+        ``api_key: ${KEY_ENV}`` from the resolved secret and wrote it to the\n+        ``providers.<key>`` entry, cluttering configs that intentionally keep\n+        credentials out of ``config.yaml``. The entry already carries\n+        ``key_env``; the runtime resolves it directly, so no inline\n+        ``api_key`` belongs on disk.\n+        \"\"\"\n+        import yaml\n+        from hermes_cli.main import _model_flow_named_custom\n+\n+        config_path = config_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"providers:\\n\"\n+            \"  crs-henkee:\\n\"\n+            \"    name: CRS Henkee\\n\"\n+            \"    base_url: http://127.0.0.1:3000/api/v1\\n\"\n+            \"    key_env: HERMES_CRS_HENKEE_KEY\\n\"\n+            \"    transport: anthropic_messages\\n\"\n+            \"    model: claude-opus-4-7\\n\"\n+            \"    default_model: claude-opus-4-7\\n\"\n+            \"custom_providers: []\\n\"\n+        )\n+        monkeypatch.setenv(\"HERMES_CRS_HENKEE_KEY\", \"cr_live_secret_xyz\")\n+\n+        # provider_info as built by _named_custom_provider_map for a\n+        # ``providers:`` entry that has key_env but no inline api_key.\n+        provider_info = {\n+            \"name\": \"CRS Henkee\",\n+            \"base_url\": \"http://127.0.0.1:3000/api/v1\",\n+            \"api_key\": \"\",\n+            \"key_env\": \"HERMES_CRS_HENKEE_KEY\",\n+            \"model\": \"claude-opus-4-7\",\n+            \"api_mode\": \"anthropic_messages\",\n+            \"provider_key\": \"crs-henkee\",\n+            \"api_key_ref\": \"\",\n+        }\n+\n+        with patch(\n+            \"hermes_cli.models.fetch_api_models\",\n+            return_value=[\"claude-opus-4-7\"],\n+        ) as mock_fetch, \\\n+             patch.dict(\"sys.modules\", {\"simple_term_menu\": None}), \\\n+             patch(\"builtins.input\", return_value=\"1\"), \\\n+             patch(\"builtins.print\"):\n+            _model_flow_named_custom({}, provider_info)\n+\n+        # The /models probe must resolve the secret from the env var.\n+        mock_fetch.assert_called_once()\n+        probe_args, _ = mock_fetch.call_args\n+        assert probe_args[0] == \"cr_live_secret_xyz\"\n+\n+        # The providers entry must NOT gain an api_key field — neither the\n+        # plaintext secret nor a synthesized ${KEY_ENV} template.\n+        saved_text = config_path.read_text()\n+        saved = yaml.safe_load(saved_text) or {}\n+        entry = saved[\"providers\"][\"crs-henkee\"]\n+        assert \"api_key\" not in entry, (\n+            f\"providers.crs-henkee gained an api_key field: {entry.get('api_key')!r}\"\n+        )\n+        assert entry[\"key_env\"] == \"HERMES_CRS_HENKEE_KEY\"\n+        assert entry[\"default_model\"] == \"claude-opus-4-7\"\n+\n+        # And the plaintext secret must never appear anywhere on disk.\n+        assert \"cr_live_secret_xyz\" not in saved_text\n+        # The synthesized template is also redundant here — key_env owns it.\n+        assert \"${HERMES_CRS_HENKEE_KEY}\" not in saved_text\n+\n+    def test_key_env_providers_dict_preserves_existing_api_key(\n+        self, config_home, monkeypatch\n+    ):\n+        \"\"\"A ``providers:`` entry that already has an inline ``api_key``\n+        template must keep it untouched. Only entries that never declared\n+        an ``api_key`` should skip the write.\"\"\"\n+        import yaml\n+        from hermes_cli.main import _model_flow_named_custom\n+\n+        config_path = config_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"providers:\\n\"\n+            \"  crs-henkee:\\n\"\n+            \"    name: CRS Henkee\\n\"\n+            \"    base_url: http://127.0.0.1:3000/api/v1\\n\"\n+            \"    api_key: ${HERMES_CRS_HENKEE_KEY}\\n\"\n+            \"    key_env: HERMES_CRS_HENKEE_KEY\\n\"\n+            \"    transport: anthropic_messages\\n\"\n+            \"    model: claude-opus-4-7\\n\"\n+            \"    default_model: claude-opus-4-7\\n\"\n+            \"custom_providers: []\\n\"\n+        )\n+        monkeypatch.setenv(\"HERMES_CRS_HENKEE_KEY\", \"cr_live_secret_xyz\")\n+\n+        provider_info = {\n+            \"name\": \"CRS Henkee\",\n+            \"base_url\": \"http://127.0.0.1:3000/api/v1\",\n+            \"api_key\": \"[redacted:secret-assignment]\",  # expanded by load_config\n+            \"key_env\": \"HERMES_CRS_HENKEE_KEY\",\n+            \"model\": \"claude-opus-4-7\",\n+            \"api_mode\": \"anthropic_messages\",\n+            \"provider_key\": \"crs-henkee\",\n+            \"api_key_ref\": \"${HERMES_CRS_HENKEE_KEY}\",  # raw template preserved\n+        }\n+\n+        with patch(\n+            \"hermes_cli.models.fetch_api_models\",\n+            return_value=[\"claude-opus-4-7\"],\n+        ), \\\n+             patch.dict(\"sys.modules\", {\"simple_term_menu\": None}), \\\n+             patch(\"builtins.input\", return_value=\"1\"), \\\n+             patch(\"builtins.print\"):\n+            _model_flow_named_custom({}, provider_info)\n+\n+        saved_text = config_path.read_text()\n+        saved = yaml.safe_load(saved_text) or {}\n+        entry = saved[\"providers\"][\"crs-henkee\"]\n+        # Existing api_key template must survive (the resolved secret must not\n+        # clobber it via _preserve_env_ref_templates).\n+        assert entry[\"api_key\"] == \"${HERMES_CRS_HENKEE_KEY}\"\n+        assert \"cr_live_secret_xyz\" not in saved_text\n"]}]},{"kind":"pr","number":17351,"title":"fix(models): strip env-resolved api_key before persisting provider config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8258f4dcb","number":0},{"kind":"pr","value":"#16372","number":16372},{"kind":"pr","value":"#15803","number":15803}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}