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

hermes config show printed the model dict raw via print(), bypassing the
logging redactor; a custom-provider api_key (e.g. Cloudflare cfut_...) was
shown in plaintext even with security.redact_secrets=true. Opaque tokens
don't match any vendor-prefix regex, so structural key-name masking is
required.

- Add redact_config_value(): recursively masks credential-shaped keys
  (api_key/token/secret/... exact-match) via mask_secret.
- Wrap the show_config model dump in it.
- Mask the set_config_value echo when the leaf key is credential-shaped
  (config set model.api_key routes to config.yaml, lowercase misses the
  .env allowlist).
This commit is contained in:
Teknium 2026-06-21 11:50:31 -07:00 committed by GitHub
parent e0498bd305
commit a18bae65b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 120 additions and 2 deletions

View file

@ -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}")
# =============================================================================

View file

@ -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