# NousResearch/hermes-agent · Redact model api_key in hermes config command output

## Open now

- Nothing open on GitHub.

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.

## TL;DR

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

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: 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.

## Closed / duplicate issues

- #50245: hermes config set exposes model.api_key in command output despite security.redact_secrets=true

## Prior & competing PRs

- **#50313: fix(config): redact api_key in config show/set output (#50245)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/config.py`, `tests/hermes_cli/test_set_config_value.py`
  - fix hunks:
  - `hermes_cli/config.py`

    ```diff
    @@ -6400,6 +6400,60 @@ def redact_key(key: str) -> str:
         return mask_secret(key, empty=color("(not set)", Colors.DIM))
     
     
    +# Key names (case-insensitive, exact match) whose VALUE is a credential and
    +# must be masked before printing any config dict to the terminal. Covers the
    +# fields a custom provider stuffs into the `model`/`custom_providers` blocks
    +# (`api_key`) plus the usual token/secret/password shapes. Exact-match only so
    +# benign keys like `token_count` or `secret_santa` don't get masked.
    +_SECRET_CONFIG_KEYS = [redacted:secret-assignment]
    +    "api_key",
    +    "apikey",
    +    "key",
    +    "token",
    +    "access_token",
    +    "refresh_token",
    +    "id_token",
    +    "secret",
    +    "client_secret",
    +    "password",
    +    "passwd",
    +    "auth",
    +    "authorization",
    +    "private_key",
    +    "bearer",
    +    "jwt",
    +})
    +
    +
    +def redact_config_value(value: Any, _depth: int = 0) -> Any:
    +    """Return a copy of ``value`` with credential-shaped keys masked for display.
    +
    +    Recursively walks dicts/lists and replaces the value of any key in
    +    ``_SECRET_CONFIG_KEYS`` (case-insensitive) with a masked form via
    +    :func:`agent.redact.mask_secret`. Non-secret keys and scalar values pass
    +    through unchanged. Use this before ``print``-ing any config sub-tree that
    +    might carry a custom-provider ``api_key`` — ``print`` bypasses the logging
    +    redactor, and opaque tokens (e.g. Cloudflare ``cfut_...``) don't match the
    +    vendor-prefix regexes either, so structural key-name masking is required.
    +    """
    +    from agent.redact import mask_secret
    +
    +    # Defensive bound on recursion depth for pathological/cyclic configs.
    +    if _depth > 20:
    +        return value
    +    if isinstance(value, dict):
    +        out = {}
    +        for k, v in value.items():
    +            if isinstance(k, str) and k.lower() in _SECRET_CONFIG_KEYS and isinstance(v, str) and v:
    +                out[k] = mask_secret(v)
    +            else:
    +                out[k] = redact_config_value(v, _depth + 1)
    +        return out
    +    if isinstance(value, list):
    +        return [redact_config_value(v, _depth + 1) for v in value]
    +    return value
    +
    +
     def show_config():
         """Display current configuration."""
         config = load_config()
    ```

    ```diff
    @@ -6468,7 +6522,7 @@ def show_config():
         # Model settings
         print()
         print(color("◆ Model", Colors.CYAN, Colors.BOLD))
    -    print(f"  Model:        {config.get('model', 'not set')}")
    +    print(f"  Model:        {redact_config_value(config.get('model', 'not set'))}")
         _cfg_max_turns = config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])
         print(f"  Max turns:    {_cfg_max_turns}")
         # Warn on stale HERMES_MAX_ITERATIONS ghost in .env that disagrees with
    ```

    ```diff
    @@ -6726,7 +6780,17 @@ def set_config_value(key: str, value: str):
         if env_var and key != "terminal.cwd":
             save_env_value(env_var, _terminal_env_value(value))
     
    -    print(f"✓ Set {key} = {value} in {config_path}")
    +    # Mask the echoed value when the (possibly nested) key is credential-shaped
    +    # — e.g. `hermes config set model.api_key cfut_...` routes to config.yaml
    +    # (lowercase, so it misses the .env api_keys list above) and would otherwise
    +    # print the raw secret to the terminal.
    +    _leaf_key = key.rsplit(".", 1)[-1].lower()
    +    if _leaf_key in _SECRET_CONFIG_KEYS and isinstance(value, str) and value:
    +        from agent.redact import mask_secret
    +        _display_value = mask_secret(value)
    +    else:
    +        _display_value = value
    +    print(f"✓ Set {key} = {_display_value} in {config_path}")
     
     
     # =============================================================================
    ```
  - `tests/hermes_cli/test_set_config_value.py`

    ```diff
    @@ -247,3 +247,57 @@ def test_deeper_nesting_through_list(self, _isolated_hermes_home):
             assert isinstance(allowlist, list)
             assert allowlist[0] == {"name": "alice", "role": "admin"}
             assert allowlist[1] == {"name": "bob", "role": "admin"}
    +
    +
    +# ---------------------------------------------------------------------------
    +# Secret redaction in display output (issue #50245)
    +# ---------------------------------------------------------------------------
    +
    +class TestSecretRedactionInDisplay:
    +    """`config set`/`config show` must not echo credential values in plaintext."""
    +
    +    def test_redact_config_value_masks_nested_api_key(self):
    +        from hermes_cli.config import redact_config_value
    +        secret = "[redacted:secret-assignment]"
    +        model = {"default": "@cf/foo", "provider": "custom", "api_key": [redacted:secret-assignment]
    +
    +        out = redact_config_value(model)
    +
    +        assert out["api_key"] != secret
    +        assert secret not in str(out)
    +        # Non-secret fields pass through unchanged.
    +        assert out["default"] == "@cf/foo"
    +        assert out["provider"] == "custom"
    +
    +    def test_redact_config_value_walks_lists(self):
    +        from hermes_cli.config import redact_config_value
    +        secret = "[redacted:openai-key]"
    +        cfg = {"custom_providers": [{"name": "p", "api_key": [redacted:secret-assignment]
    +
    +        out = redact_config_value(cfg)
    +
    +        assert secret not in str(out)
    +        assert out["custom_providers"][0]["name"] == "p"
    +
    +    def test_redact_config_value_ignores_benign_keys(self):
    +        from hermes_cli.config import redact_config_value
    +        cfg = {"token_count": 1234, "secret_santa": "alice", "max_turns": 90}
    +
    +        out = redact_config_value(cfg)
    +
    +        # Exact-match only — substrings like token_count must NOT be masked.
    +        assert out == cfg
    +
    +    def test_set_echo_masks_secret_value(self, _isolated_hermes_home, capsys):
    +        secret = "[redacted:secret-assignment]"
    +        set_config_value("model.api_key", secret)
    +
    +        captured = capsys.readouterr()
    +        assert secret not in captured.out
    +        assert "Set model.api_key" in captured.out
    +
    +    def test_set_echo_keeps_nonsecret_value(self, _isolated_hermes_home, capsys):
    +        set_config_value("model.reasoning_effort", "high")
    +
    +        captured = capsys.readouterr()
    +        assert "Set model.reasoning_effort = high" in captured.out
    ```
- **#50259: fix(config): redact model api_key in config show output**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#50313`, `#50245`

## Fix status

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