diff --git a/.install_method b/.install_method new file mode 100644 index 000000000000..5664e303b5dc --- /dev/null +++ b/.install_method @@ -0,0 +1 @@ +git diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 050f1975db97..d80c262cdaea 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -6153,17 +6153,59 @@ def _strip_dotted_keys(cfg: dict, dotted_keys: set) -> Tuple[dict, set]: return cfg, stripped +def _env_expand_match(m: re.Match) -> str: + """Expand a ``${...}`` match, supporting both ``${VAR}`` and ``${env:VAR}``. + + ``${VAR}`` resolves via ``os.environ.get("VAR")`` (legacy). + ``${env:VAR}`` strips the ``env:`` prefix and resolves the same way. + + Unresolved references are logged at warning level and returned verbatim. + """ + raw = m.group(0) + inner = m.group(1) + if inner.startswith("env:"): + name = inner[len("env:"):] + if not name: + return raw + val = os.environ.get(name) + if val is not None: + return val + logger.warning( + "Config env-ref %r uses source 'env' but %s is not set " + "(check ~/.hermes/.env or HERMES_PROFILE/.env)", + raw, name, + ) + elif inner.startswith("file:"): + logger.warning( + "Config env-ref %r uses source 'file' which is not yet supported; " + "keeping verbatim", raw, + ) + elif inner.startswith(("bitwarden:", "vault:", "aws:")): + logger.warning( + "Config env-ref %r uses source %r which is not yet supported; " + "keeping verbatim", raw, inner.split(":")[0], + ) + else: + # Legacy ``${VAR}`` — bare name, no source prefix + val = os.environ.get(inner) + if val is not None: + return val + return raw + + def _expand_env_vars(obj): - """Recursively expand ``${VAR}`` references in config values. + """Recursively expand ``${VAR}`` and ``${env:VAR}`` references in config values. Only string values are processed; dict keys, numbers, booleans, and None are left untouched. Unresolved references (variable not in ``os.environ``) are kept verbatim so callers can detect them. + + Since 2026.7 — also supports ``${source:name}`` SecretRef format. """ if isinstance(obj, str): return re.sub( r"\${([^}]+)}", - lambda m: os.environ.get(m.group(1), m.group(0)), + _env_expand_match, obj, ) if isinstance(obj, dict): @@ -6174,8 +6216,8 @@ def _expand_env_vars(obj): def _env_ref_snapshot(obj, snapshot=None): - """Map every ``${VAR}`` name referenced in config values to its current - ``os.environ`` value (``None`` when unset). + """Map every ``${VAR}`` / ``${env:VAR}`` name referenced in config values + to its current ``os.environ`` value (``None`` when unset). Stored alongside cached ``load_config()`` results so a cache hit can detect that the cached expansion was made against a *different* @@ -6183,11 +6225,17 @@ def _env_ref_snapshot(obj, snapshot=None): ``load_hermes_dotenv()`` populated the process env, or an env var rotated in-process after the first load. File mtime/size alone cannot see either case (#58514). + + Since 2026.7 — strips ``env:`` prefix from ``${env:VAR}`` references + so the snapshot tracks the real env-var name, not ``"env:VAR"``. """ if snapshot is None: snapshot = {} if isinstance(obj, str): - for name in re.findall(r"\${([^}]+)}", obj): + for raw in re.findall(r"\${([^}]+)}", obj): + name = raw[len("env:"):] if raw.startswith("env:") else raw + if raw.startswith(("file:", "bitwarden:", "vault:", "aws:")): + continue snapshot[name] = os.environ.get(name) elif isinstance(obj, dict): for value in obj.values(): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index caca5e6a8a37..3a1f9ab2f092 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12812,11 +12812,28 @@ def main(): _secrets_cli.register_cli(secrets_bw) + # ``hermes secrets audit`` — scan for plaintext keys + audit_parser = secrets_subparsers.add_parser( + "audit", + help="Scan for plaintext API keys in skills, memory, and config", + ) + audit_parser.add_argument( + "--check", action="store_true", + help="Exit non-zero if plaintext keys are found (CI/CD mode)", + ) + audit_parser.add_argument( + "--fix", action="store_true", + help="Suggest SecretRef replacements for detected plaintext keys", + ) + audit_parser.set_defaults(func=lambda a: _secrets_cli.cmd_audit(a)) + def _dispatch_secrets(args): # noqa: ANN001 sub = getattr(args, "secrets_command", None) bw_sub = getattr(args, "secrets_bw_command", None) if sub in ("bitwarden", "bw") and bw_sub is not None: return args.func(args) + if sub == "audit": + return _secrets_cli.cmd_audit(args) secrets_parser.print_help() return 0 @@ -14138,10 +14155,12 @@ def cmd_sessions(args): return # Execute the command + rc = 0 if hasattr(args, "func"): - args.func(args) + rc = args.func(args) or 0 else: parser.print_help() + sys.exit(rc) if __name__ == "__main__": diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index cc31cb331609..3dd90fc654c6 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -13,6 +13,7 @@ import argparse import json import os +import re import subprocess import sys from pathlib import Path @@ -598,3 +599,96 @@ def _resolve_server_url( ) return custom console.print(f" [red]Out of range — pick 1-{custom_idx}.[/red]") + + +_AUDIT_KEY_PATTERNS: list[tuple[str, str, str]] = [ + # (regex pattern, source_label, fix_suggestion_hint) + (r"(?i)\b(sk-[a-z0-9]{20,})\b", "OpenAI-style sk-", "env:OPENAI_API_KEY"), + (r"(?i)\b(xai-[a-zA-Z0-9]{20,})\b", "xAI key", "env:XAI_API_KEY"), + (r"(?i)\b(ds-[a-z0-9]{20,})\b", "DeepSeek key", "env:DEEPSEEK_API_KEY"), + (r"(?i)\b(nvapi-[a-z0-9-]{20,})\b", "NVIDIA key", "env:NVIDIA_API_KEY"), + (r"(?i)\b(Bearer\s+[a-zA-Z0-9_-]{20,})\b", "Bearer token in header value", "env:VAR_NAME"), +] + + +def _scan_file_for_keys( + file_path: Path, + console: Console, +) -> list[dict]: + """Scan a single skill/memory file for plaintext API key patterns. + + Returns a list of dicts: {path, line, match, source, fix_hint}. + """ + findings: list[dict] = [] + try: + text = file_path.read_text(encoding="utf-8", errors="replace") + except (OSError, UnicodeDecodeError): + return findings + + for lineno, line in enumerate(text.splitlines(), 1): + for pattern, source, fix_hint in _AUDIT_KEY_PATTERNS: + for m in re.finditer(pattern, line): + # Skip lines that already use SecretRef format + if "${env:" in line or "${file:" in line: + continue + findings.append({ + "path": str(file_path), + "line": lineno, + "match": m.group(0)[:40], + "source": source, + "fix_hint": fix_hint, + }) + break # one finding per line is enough + return findings + + +def cmd_audit(args: argparse.Namespace) -> int: + """``hermes secrets audit`` — scan for plaintext API keys.""" + console = Console() + + # Load .env first so SecretRef references resolve cleanly during scan + from hermes_cli.env_loader import load_hermes_dotenv + + load_hermes_dotenv() + + hermes_home = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) + scan_dirs = [hermes_home / "skills", hermes_home / "memories"] + config_path = hermes_home / "config.yaml" + + findings: list[dict] = [] + + # Scan skills and memories + for scan_dir in scan_dirs: + if not scan_dir.is_dir(): + continue + for f in sorted(scan_dir.rglob("*.md")): + findings.extend(_scan_file_for_keys(f, console)) + + # Scan config.yaml + if config_path.is_file(): + findings.extend(_scan_file_for_keys(config_path, console)) + + if not findings: + console.print("[green]✓ No plaintext API keys found in skills, memory, or config.[/green]") + return 0 + + console.print(f"[yellow]Found {len(findings)} potential plaintext key(s):[/yellow]\\n") + for f in findings: + console.print( + f" {f['path']}:{f['line']} " + f"[red]{f['source']}[/red] " + f"…{f['match']}…" + ) + if args.fix: + console.print( + f" → hint: replace with [cyan]${{{f['fix_hint']}}}[/cyan]" + ) + + console.print( + f"\\n Hint: move secrets to ~/.hermes/.env and reference them as " + f"[cyan]${{env:VAR_NAME}}[/cyan] in config.yaml" + ) + + if args.check: + return 1 # exit non-zero for CI/CD + return 0 diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 71c8a635555b..9c8b0884d6a5 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3482,7 +3482,16 @@ def _interpolate_env_vars(value): if isinstance(value, str): def _replace(m): name = _env_ref_name(m.group(1)) - return _get_secret(name, m.group(0)) or m.group(0) + resolved = _get_secret(name, None) + if resolved is not None: + return resolved + # Unresolved — log a warning and keep the literal placeholder + logger.warning( + "MCP config env-ref ${%s} (%s) is not set — " + "keeping literal placeholder", + m.group(1), name, + ) + return m.group(0) return _ENV_VAR_PATTERN.sub(_replace, value) if isinstance(value, dict): return {k: _interpolate_env_vars(v) for k, v in value.items()}