From a9a8a3fa2e63bea3d55a2c124a99b7c2bd24621d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:54:02 -0700 Subject: [PATCH] fix(config): `hermes config get` masks credentials on every path; `--raw` opts out `hermes config get providers`, `config get providers.

.api_key`, `config get _API_KEY` (the .env-routed branch) and `config get mcp_servers..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 --- hermes_cli/config.py | 27 +++++++++++++---- hermes_cli/subcommands/config.py | 3 ++ tests/hermes_cli/test_set_config_value.py | 36 +++++++++++++++++++++++ website/docs/reference/cli-commands.md | 2 +- 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a543bb2810..fd0377b997 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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..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 [--json]", [ +_USAGE_GET = ("Usage: hermes config get [--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] ", [ @@ -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): diff --git a/hermes_cli/subcommands/config.py b/hermes_cli/subcommands/config.py index 26b85808d9..b7ca790ec8 100644 --- a/hermes_cli/subcommands/config.py +++ b/hermes_cli/subcommands/config.py @@ -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( diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index f1fc63dc81..b25021b654 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -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 diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 19b5e23655..f61f120958 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1227,7 +1227,7 @@ Subcommands: |------------|-------------| | `show` | Show current config values. | | `edit` | Open `config.yaml` in your editor. | -| `get [--json]` | Print a single config value by dotted key (e.g. `hermes config get model.default`). `--json` emits machine-readable output. | +| `get [--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 ` | Set a config value. | | `unset ` | Remove a config key, reverting it to the built-in default. | | `path` | Print the config file path. |