fix(config): hermes config get masks credentials on every path; --raw opts out
`hermes config get providers`, `config get providers.<p>.api_key`, `config get <PROVIDER>_API_KEY` (the .env-routed branch) and `config get mcp_servers.<s>.env.X_API_KEY` all printed the full credential. The agent runs this command from sessions whose transcripts persist and get forwarded (a Gemini key surfaced in a Discord DM log), so `print` output is a leak path the logging redactor never sees. `get_config_value` now applies the structural masker used by `config show` before printing, honouring `security.redact_secrets` (default on), with a `--raw` flag for operators/scripts that need the real value. `_is_secret_config_key` extends the exact-name set with the same `*_API_KEY / *_TOKEN / *_SECRET / *_PASSWORD` suffixes `_is_env_config_key` already routes to .env, so env-map leaves under `mcp_servers.*.env` mask too, and the `config set` echo uses the same predicate. Slim redo of #84153 by @webtecnica (same direction: mask in get_config_value; dropped the redact_url_query_params re-export and the separate redaction-enabled reader in favour of agent.redact._redact_enabled, which already resolves the profile-scoped policy). Fixes #110758 Fixes #84106
This commit is contained in:
+21
-6
@@ -2753,6 +2753,15 @@ _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"})
|
||||
# Env-map shapes (``mcp_servers.<s>.env.FOO_API_KEY``, ``GEMINI_API_KEY``) — the same suffixes
|
||||
# ``_is_env_config_key`` routes to .env. Suffix-only so ``token_count`` stays visible.
|
||||
_SECRET_CONFIG_KEY_SUFFIXES = ("_api_key", "_token", "_secret", "_password")
|
||||
|
||||
|
||||
def _is_secret_config_key(key: str) -> bool:
|
||||
"""Whether the LAST segment of a config key names a credential value."""
|
||||
leaf = key.rsplit(".", 1)[-1].lower()
|
||||
return leaf in _SECRET_CONFIG_KEYS or leaf.endswith(_SECRET_CONFIG_KEY_SUFFIXES)
|
||||
|
||||
|
||||
def redact_config_value(value: Any, _depth: int = 0) -> Any:
|
||||
@@ -2765,7 +2774,7 @@ def redact_config_value(value: Any, _depth: int = 0) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: mask_secret(v)
|
||||
if isinstance(k, str) and k.lower() in _SECRET_CONFIG_KEYS and isinstance(v, str) and v
|
||||
if isinstance(k, str) and _is_secret_config_key(k) and isinstance(v, str) and v
|
||||
else redact_config_value(v, _depth + 1)
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
@@ -3521,7 +3530,7 @@ def set_config_value(key: str, value: str, force: bool = False):
|
||||
# Mask the echoed value when the (possibly nested) key is credential-shaped, e.g.
|
||||
# ``model.api_key`` (lowercase, so it misses the .env routing above).
|
||||
_display_value = value
|
||||
if key.rsplit(".", 1)[-1].lower() in _SECRET_CONFIG_KEYS and isinstance(value, str) and value:
|
||||
if _is_secret_config_key(key) and isinstance(value, str) and value:
|
||||
from agent.redact import mask_secret
|
||||
_display_value = mask_secret(value)
|
||||
print(f"✓ Set {key} = {_display_value} in {config_path}")
|
||||
@@ -3533,8 +3542,10 @@ def set_config_value(key: str, value: str, force: bool = False):
|
||||
_print_unknown_key_notice(key, suggestion)
|
||||
|
||||
|
||||
def get_config_value(key: str, *, as_json: bool = False):
|
||||
"""Print a resolved configuration value."""
|
||||
def get_config_value(key: str, *, as_json: bool = False, raw: bool = False):
|
||||
"""Print a resolved configuration value. Credentials are masked unless ``--raw`` or
|
||||
``security.redact_secrets: false``: ``print`` bypasses the log redactor, and the agent runs
|
||||
this command from sessions whose transcripts persist (#84106, #110758)."""
|
||||
if _is_env_config_key(key):
|
||||
env_value = get_env_value(key.upper())
|
||||
value = _MISSING if env_value is None else env_value
|
||||
@@ -3547,6 +3558,10 @@ def get_config_value(key: str, *, as_json: bool = False):
|
||||
if value is _MISSING:
|
||||
_exit_invalid(f"Config key not set: {key}")
|
||||
|
||||
from agent.redact import _redact_enabled, mask_secret
|
||||
if not raw and _redact_enabled():
|
||||
value = mask_secret(value) if isinstance(value, str) and _is_secret_config_key(key) else redact_config_value(value)
|
||||
|
||||
print(_format_config_get_value(value, as_json=as_json))
|
||||
|
||||
|
||||
@@ -3610,7 +3625,7 @@ def _run_write_command(fn, *args) -> None:
|
||||
_exit_invalid(f"✗ {exc}")
|
||||
|
||||
|
||||
_USAGE_GET = ("Usage: hermes config get <key> [--json]", [
|
||||
_USAGE_GET = ("Usage: hermes config get <key> [--json] [--raw]", [
|
||||
"hermes config get model", "hermes config get terminal.backend",
|
||||
"hermes config get skills.config --json"], None)
|
||||
_USAGE_SET = ("Usage: hermes config set [--force] <key> <value>", [
|
||||
@@ -3627,7 +3642,7 @@ def _cmd_config_get(args):
|
||||
key = getattr(args, 'key', None)
|
||||
if not key:
|
||||
_usage_exit(*_USAGE_GET)
|
||||
get_config_value(key, as_json=getattr(args, 'json', False))
|
||||
get_config_value(key, as_json=getattr(args, 'json', False), raw=bool(getattr(args, 'raw', False)))
|
||||
|
||||
|
||||
def _cmd_config_set(args):
|
||||
|
||||
@@ -20,6 +20,9 @@ def build_config_parser(subparsers, *, cmd_config: Callable) -> None:
|
||||
config_get = config_subparsers.add_parser("get", help="Print a resolved configuration value")
|
||||
config_get.add_argument("key", nargs="?", help="Configuration key (e.g., model)")
|
||||
add_json_flag(config_get, "Print value as JSON")
|
||||
config_get.add_argument(
|
||||
"--raw", action="store_true",
|
||||
help="Print credential values unmasked (default masks api_key/token/secret-shaped values)")
|
||||
|
||||
config_set = config_subparsers.add_parser("set", help="Set a configuration value")
|
||||
config_set.add_argument(
|
||||
|
||||
@@ -856,3 +856,39 @@ class TestLiteralDotKeyEscaping:
|
||||
import yaml
|
||||
saved = yaml.safe_load(_read_config(_isolated_hermes_home))
|
||||
assert saved["terminal"]["backend"] == "docker"
|
||||
|
||||
|
||||
class TestConfigGetRedaction:
|
||||
"""#84106 / #110758: `config get` is run by the agent from persisted sessions, so every
|
||||
path (section dump, dotted leaf, .env-routed key) masks credentials unless ``--raw``."""
|
||||
|
||||
SECRET = "OPAQUEKEYVALUE12345678"
|
||||
|
||||
def _seed(self, home, monkeypatch):
|
||||
(home / "config.yaml").write_text(
|
||||
"providers:\n gemini:\n api_key: " + self.SECRET + "\n"
|
||||
"mcp_servers:\n s:\n env:\n MY_API_KEY: ${MY_API_KEY}\n url: https://x.example\n",
|
||||
encoding="utf-8")
|
||||
(home / ".env").write_text("GEMINI_API_KEY=" + self.SECRET + "\n", encoding="utf-8")
|
||||
monkeypatch.setenv("MY_API_KEY", self.SECRET)
|
||||
|
||||
@pytest.mark.parametrize("key", ["providers", "providers.gemini.api_key", "GEMINI_API_KEY",
|
||||
"mcp_servers.s.env.MY_API_KEY"])
|
||||
def test_config_get_masks_every_credential_path(self, _isolated_hermes_home, capsys, monkeypatch, key):
|
||||
self._seed(_isolated_hermes_home, monkeypatch)
|
||||
from hermes_cli.config import get_config_value
|
||||
|
||||
get_config_value(key)
|
||||
out = capsys.readouterr().out
|
||||
assert self.SECRET not in out
|
||||
# Still identifies the key (mask keeps head/tail) and non-secret siblings stay readable.
|
||||
assert self.SECRET[:4] in out
|
||||
if key == "providers":
|
||||
assert "gemini" in out
|
||||
|
||||
def test_config_get_raw_prints_the_real_value(self, _isolated_hermes_home, capsys, monkeypatch):
|
||||
self._seed(_isolated_hermes_home, monkeypatch)
|
||||
from hermes_cli.config import get_config_value
|
||||
|
||||
get_config_value("providers.gemini.api_key", raw=True)
|
||||
assert capsys.readouterr().out.strip() == self.SECRET
|
||||
|
||||
@@ -1227,7 +1227,7 @@ Subcommands:
|
||||
|------------|-------------|
|
||||
| `show` | Show current config values. |
|
||||
| `edit` | Open `config.yaml` in your editor. |
|
||||
| `get <key> [--json]` | Print a single config value by dotted key (e.g. `hermes config get model.default`). `--json` emits machine-readable output. |
|
||||
| `get <key> [--json] [--raw]` | Print a single config value by dotted key (e.g. `hermes config get model.default`). `--json` emits machine-readable output. Credential-shaped values (`api_key`, `*_TOKEN`, `*_SECRET`, `password`, …) are masked (`sk-o...7890`) because the agent runs this from sessions whose transcripts persist; `--raw` prints the real value (or set `security.redact_secrets: false`). |
|
||||
| `set <key> <value>` | Set a config value. |
|
||||
| `unset <key>` | Remove a config key, reverting it to the built-in default. |
|
||||
| `path` | Print the config file path. |
|
||||
|
||||
Reference in New Issue
Block a user