{"repo":"NousResearch/hermes-agent","public_id":"50245","display_label":"Redact model api_key in hermes config command output","summary":"The three members all concern the config commands (show/set) displaying model.api_key, with one issue reporting exposure despite security.redact_secrets=true and two pull requests titled as fixes that redact it. The titles alone do not show whether any change was merged or whether the behavior is now handled.","subsystem":"hermes_cli","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":50313,"merged_at":"2026-06-21T18:50:31+00:00"},"resolution":{"state":"resolved","by":"pr-50313"},"freshness":{"state":"verified","last_verified_at":"2026-06-21T18:50:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":50245,"pr_number":50313,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-21T18:50:31+00:00"}],"members":[{"kind":"issue","number":50245,"title":"hermes config set exposes model.api_key in command output despite security.redact_secrets=true","url":null,"state":"closed"},{"kind":"pr","number":50259,"title":"fix(config): redact model api_key in config show output","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#50313","number":50313},{"kind":"pr","value":"#50245","number":50245}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":50313,"title":"fix(config): redact api_key in config show/set output (#50245)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","tests/hermes_cli/test_set_config_value.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -6400,6 +6400,60 @@ def redact_key(key: str) -> str:\n     return mask_secret(key, empty=color(\"(not set)\", Colors.DIM))\n \n \n+# Key names (case-insensitive, exact match) whose VALUE is a credential and\n+# must be masked before printing any config dict to the terminal. Covers the\n+# fields a custom provider stuffs into the `model`/`custom_providers` blocks\n+# (`api_key`) plus the usual token/secret/password shapes. Exact-match only so\n+# benign keys like `token_count` or `secret_santa` don't get masked.\n+_SECRET_CONFIG_KEYS = [redacted:secret-assignment]\n+    \"api_key\",\n+    \"apikey\",\n+    \"key\",\n+    \"token\",\n+    \"access_token\",\n+    \"refresh_token\",\n+    \"id_token\",\n+    \"secret\",\n+    \"client_secret\",\n+    \"password\",\n+    \"passwd\",\n+    \"auth\",\n+    \"authorization\",\n+    \"private_key\",\n+    \"bearer\",\n+    \"jwt\",\n+})\n+\n+\n+def redact_config_value(value: Any, _depth: int = 0) -> Any:\n+    \"\"\"Return a copy of ``value`` with credential-shaped keys masked for display.\n+\n+    Recursively walks dicts/lists and replaces the value of any key in\n+    ``_SECRET_CONFIG_KEYS`` (case-insensitive) with a masked form via\n+    :func:`agent.redact.mask_secret`. Non-secret keys and scalar values pass\n+    through unchanged. Use this before ``print``-ing any config sub-tree that\n+    might carry a custom-provider ``api_key`` — ``print`` bypasses the logging\n+    redactor, and opaque tokens (e.g. Cloudflare ``cfut_...``) don't match the\n+    vendor-prefix regexes either, so structural key-name masking is required.\n+    \"\"\"\n+    from agent.redact import mask_secret\n+\n+    # Defensive bound on recursion depth for pathological/cyclic configs.\n+    if _depth > 20:\n+        return value\n+    if isinstance(value, dict):\n+        out = {}\n+        for k, v in value.items():\n+            if isinstance(k, str) and k.lower() in _SECRET_CONFIG_KEYS and isinstance(v, str) and v:\n+                out[k] = mask_secret(v)\n+            else:\n+                out[k] = redact_config_value(v, _depth + 1)\n+        return out\n+    if isinstance(value, list):\n+        return [redact_config_value(v, _depth + 1) for v in value]\n+    return value\n+\n+\n def show_config():\n     \"\"\"Display current configuration.\"\"\"\n     config = load_config()","@@ -6468,7 +6522,7 @@ def show_config():\n     # Model settings\n     print()\n     print(color(\"◆ Model\", Colors.CYAN, Colors.BOLD))\n-    print(f\"  Model:        {config.get('model', 'not set')}\")\n+    print(f\"  Model:        {redact_config_value(config.get('model', 'not set'))}\")\n     _cfg_max_turns = config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])\n     print(f\"  Max turns:    {_cfg_max_turns}\")\n     # Warn on stale HERMES_MAX_ITERATIONS ghost in .env that disagrees with","@@ -6726,7 +6780,17 @@ def set_config_value(key: str, value: str):\n     if env_var and key != \"terminal.cwd\":\n         save_env_value(env_var, _terminal_env_value(value))\n \n-    print(f\"✓ Set {key} = {value} in {config_path}\")\n+    # Mask the echoed value when the (possibly nested) key is credential-shaped\n+    # — e.g. `hermes config set model.api_key cfut_...` routes to config.yaml\n+    # (lowercase, so it misses the .env api_keys list above) and would otherwise\n+    # print the raw secret to the terminal.\n+    _leaf_key = key.rsplit(\".\", 1)[-1].lower()\n+    if _leaf_key in _SECRET_CONFIG_KEYS and isinstance(value, str) and value:\n+        from agent.redact import mask_secret\n+        _display_value = mask_secret(value)\n+    else:\n+        _display_value = value\n+    print(f\"✓ Set {key} = {_display_value} in {config_path}\")\n \n \n # ============================================================================="]},{"file":"tests/hermes_cli/test_set_config_value.py","hunks":["@@ -247,3 +247,57 @@ def test_deeper_nesting_through_list(self, _isolated_hermes_home):\n         assert isinstance(allowlist, list)\n         assert allowlist[0] == {\"name\": \"alice\", \"role\": \"admin\"}\n         assert allowlist[1] == {\"name\": \"bob\", \"role\": \"admin\"}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Secret redaction in display output (issue #50245)\n+# ---------------------------------------------------------------------------\n+\n+class TestSecretRedactionInDisplay:\n+    \"\"\"`config set`/`config show` must not echo credential values in plaintext.\"\"\"\n+\n+    def test_redact_config_value_masks_nested_api_key(self):\n+        from hermes_cli.config import redact_config_value\n+        secret = \"[redacted:secret-assignment]\"\n+        model = {\"default\": \"@cf/foo\", \"provider\": \"custom\", \"api_key\": [redacted:secret-assignment]\n+\n+        out = redact_config_value(model)\n+\n+        assert out[\"api_key\"] != secret\n+        assert secret not in str(out)\n+        # Non-secret fields pass through unchanged.\n+        assert out[\"default\"] == \"@cf/foo\"\n+        assert out[\"provider\"] == \"custom\"\n+\n+    def test_redact_config_value_walks_lists(self):\n+        from hermes_cli.config import redact_config_value\n+        secret = \"[redacted:openai-key]\"\n+        cfg = {\"custom_providers\": [{\"name\": \"p\", \"api_key\": [redacted:secret-assignment]\n+\n+        out = redact_config_value(cfg)\n+\n+        assert secret not in str(out)\n+        assert out[\"custom_providers\"][0][\"name\"] == \"p\"\n+\n+    def test_redact_config_value_ignores_benign_keys(self):\n+        from hermes_cli.config import redact_config_value\n+        cfg = {\"token_count\": 1234, \"secret_santa\": \"alice\", \"max_turns\": 90}\n+\n+        out = redact_config_value(cfg)\n+\n+        # Exact-match only — substrings like token_count must NOT be masked.\n+        assert out == cfg\n+\n+    def test_set_echo_masks_secret_value(self, _isolated_hermes_home, capsys):\n+        secret = \"[redacted:secret-assignment]\"\n+        set_config_value(\"model.api_key\", secret)\n+\n+        captured = capsys.readouterr()\n+        assert secret not in captured.out\n+        assert \"Set model.api_key\" in captured.out\n+\n+    def test_set_echo_keeps_nonsecret_value(self, _isolated_hermes_home, capsys):\n+        set_config_value(\"model.reasoning_effort\", \"high\")\n+\n+        captured = capsys.readouterr()\n+        assert \"Set model.reasoning_effort = high\" in captured.out\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}