mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
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:
parent
e0498bd305
commit
a18bae65b9
2 changed files with 120 additions and 2 deletions
|
|
@ -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}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue