NousResearch/hermes-agent

Redact model api_key in hermes config command output

Cluster 50245 · hermes cli. Data as of

Nothing open on GitHub. Fix merged in #50313 on 21 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #50313. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #50313. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #50313 fix(config): redact api_key in config show/set output (#50245)

    merged to main

    files: hermes_cli/config.py, tests/hermes_cli/test_set_config_value.py

    Show the diff
    • hermes_cli/config.py
      @@ -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()
      @@ -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
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: done elsewhere 1

  • #50259 fix(config): redact model api_key in config show output · done elsewhere → #50313, #50245
Closed issues · 1
  • Closed#50245hermes config set exposes model.api_key in command output despite security.redact_secrets=true
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#50313
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
21 Jun 2026

For your agent

The same facts, typed.