diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 27c56974b4a..0605ab83569 100644 --- a/hermes_cli/config.py +++ b/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 = frozenset({ + "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}") # ============================================================================= diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index d404549cf52..2405b84a381 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -247,3 +247,57 @@ class TestListNavigation: 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 = "cfut_SUPERSECRETTOKEN1234567890abcdef" + model = {"default": "@cf/foo", "provider": "custom", "api_key": secret} + + 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 = "sk-deadbeefdeadbeefdeadbeef" + cfg = {"custom_providers": [{"name": "p", "api_key": secret}]} + + 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 = "cfut_ANOTHERSECRET0987654321zyxwvu" + 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