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
- 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
Open PRs0
Fixes on record
-
#50313 fix(config): redact api_key in config show/set output (#50245)
files:
hermes_cli/config.py,tests/hermes_cli/test_set_config_value.pyShow 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.00means 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 LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #50313
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 21 Jun 2026