Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .install_method
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
git

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unrelated install-marker file is not part of the described feature. Please remove it from the PR.

58 changes: 53 additions & 5 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -6174,20 +6216,26 @@ 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*
environment — e.g. a ``load_config()`` that ran before
``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():
Expand Down
21 changes: 20 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not change generic main() dispatch to exit for every command just to support audit --check. Current main() returns normally and repository tests invoke it directly; scope the exit-code propagation to this command or introduce a separately tested entrypoint return-code contract.


if __name__ == "__main__":
Expand Down
94 changes: 94 additions & 0 deletions hermes_cli/secrets_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -598,3 +599,96 @@ def _resolve_server_url(
)
return custom
console.print(f" [red]Out of range — pick 1-{custom_idx}.[/red]")


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern matches the stock placeholders Bearer sk-xxxxxxxxxxxxxxxxxxxx in skills/autonomous-ai-agents/hermes-agent/references/native-mcp.md:279 and :306. Since bundled skills are synced into $HERMES_HOME/skills, secrets audit --check can report failure on a clean profile; exclude shipped examples or make the detector distinguish placeholders from credentials.

_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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use get_hermes_home() here. The manual ~/.hermes fallback misses the context-local profile override and is incorrect on native Windows, where the default is platform-native rather than ~/.hermes.


# 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
11 changes: 10 additions & 1 deletion tools/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down