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:
teknium1
2026-09-14 17:54:02 -07:00
committed by Teknium
parent 2034126e0d
commit a9a8a3fa2e
4 changed files with 61 additions and 7 deletions
+21 -6
View File
@@ -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):
+3
View File
@@ -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(
+36
View File
@@ -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
+1 -1
View File
@@ -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. |