diff --git a/agent/redact.py b/agent/redact.py index a058b71f..0a66502c 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -184,11 +184,59 @@ _PREFIX_RE = re.compile( ) +def mask_secret( + value: str, + *, + head: int = 4, + tail: int = 4, + floor: int = 12, + placeholder: str = "***", + empty: str = "", +) -> str: + """Mask a secret for display, preserving ``head`` and ``tail`` characters. + + Canonical helper for display-time redaction across Hermes — used by + ``hermes config``, ``hermes status``, ``hermes dump``, and anywhere + a secret needs to be shown truncated for debuggability while still + keeping the bulk hidden. + + Args: + value: The secret to mask. ``None``/empty returns ``empty``. + head: Leading characters to preserve. Default 4. + tail: Trailing characters to preserve. Default 4. + floor: Values shorter than ``head + tail + floor_margin`` are + fully masked (returns ``placeholder``). Default 12 — + matches the existing config/status/dump convention. + placeholder: Value returned for too-short inputs. Default ``"***"``. + empty: Value returned when ``value`` is falsy (None, ""). The + caller can override this to e.g. ``color("(not set)", + Colors.DIM)`` for user-facing display. + + Examples: + >>> mask_secret("sk-proj-abcdef1234567890") + 'sk-p...7890' + >>> mask_secret("short") # fully masked + '***' + >>> mask_secret("") # empty default + '' + >>> mask_secret("", empty="(not set)") # empty override + '(not set)' + >>> mask_secret("long-token", head=6, tail=4, floor=18) + '***' + """ + if not value: + return empty + if len(value) < floor: + return placeholder + return f"{value[:head]}...{value[-tail:]}" + + def _mask_token(token: str) -> str: - """Mask a token, preserving prefix for long tokens.""" - if len(token) < 18: + """Mask a log token — conservative 18-char floor, preserves 6 prefix / 4 suffix.""" + # Empty input: historically this returned "***" rather than "". Preserve. + if not token: return "***" - return f"{token[:6]}...{token[-4:]}" + return mask_secret(token, head=6, tail=4, floor=18) def _redact_query_string(query: str) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6d96e62e..ad3cd23b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -4013,12 +4013,13 @@ def get_env_value(key: str) -> Optional[str]: # ============================================================================= def redact_key(key: str) -> str: - """Redact an API key for display.""" - if not key: - return color("(not set)", Colors.DIM) - if len(key) < 12: - return "***" - return key[:4] + "..." + key[-4:] + """Redact an API key for display. + + Thin wrapper over :func:`agent.redact.mask_secret` — preserves the + "(not set)" placeholder in dim color for the empty case. + """ + from agent.redact import mask_secret + return mask_secret(key, empty=color("(not set)", Colors.DIM)) def show_config(): diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 3d728024..7fa9a337 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -33,12 +33,14 @@ def _get_git_commit(project_root: Path) -> str: def _redact(value: str) -> str: - """Redact all but first 4 and last 4 chars.""" - if not value: - return "" - if len(value) < 12: - return "***" - return value[:4] + "..." + value[-4:] + """Redact all but first 4 and last 4 chars. + + Thin wrapper over :func:`agent.redact.mask_secret`. Returns ``""`` for + an empty value (matches the historical behavior of this helper — + ``hermes dump`` formats empty values as blank, not as ``"(not set)"``). + """ + from agent.redact import mask_secret + return mask_secret(value) def _gateway_status() -> str: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index f02f5f26..31aa1d5c 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -26,12 +26,15 @@ def check_mark(ok: bool) -> str: return color("✗", Colors.RED) def redact_key(key: str) -> str: - """Redact an API key for display.""" - if not key: - return "(not set)" - if len(key) < 12: - return "***" - return key[:4] + "..." + key[-4:] + """Redact an API key for display. + + Thin wrapper over :func:`agent.redact.mask_secret`. Preserves the + "(not set)" placeholder in dim color to match ``hermes config``'s + output (previously this variant was missing the DIM color — + consolidated via PR that also introduced ``mask_secret``). + """ + from agent.redact import mask_secret + return mask_secret(key, empty=color("(not set)", Colors.DIM)) def _format_iso_timestamp(value) -> str: