fix(profiles): scrub secret-shaped strings from export archives

Shareable profile tarballs already drop auth.json/.env, but keys pasted
into skills, SOUL.md, or memories still shipped in plaintext. Force-run
the same redact_sensitive_text pass sessions export --redact uses on the
staged copy so the live profile is never rewritten.
This commit is contained in:
Brooklyn Nicholson
2026-08-10 16:21:17 -05:00
parent 920beecfa8
commit 6c5cb2db4a
2 changed files with 175 additions and 6 deletions

View File

@@ -1882,7 +1882,8 @@ def _default_export_ignore(root_dir: Path):
* **Universal exclusions at any depth** — ``__pycache__``, sockets,
temp files; plus npm lockfiles, which may appear at the root.
All other profile artifacts are copied through untouched.
Surviving text files are later force-redacted by
:func:`_scrub_export_secrets` before the archive is written.
"""
def _ignore(directory: str, contents: list) -> set:
@@ -1922,13 +1923,89 @@ def _make_profile_archive(base: str, root_dir: str, base_dir: str) -> str:
return archive_path
# Text / config suffixes walked during export secret scrubbing. Binary DBs,
# images, and other non-text artifacts are left alone (they may still leave
# via named-profile export — scrubbing those is a separate concern).
_EXPORT_REDACT_SUFFIXES = frozenset({
".md", ".txt", ".yaml", ".yml", ".json", ".jsonl",
".toml", ".ini", ".cfg", ".conf", ".py", ".sh",
".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx",
".css", ".html", ".xml", ".csv",
})
# pathlib.Path(".cursorrules").suffix is "" — name-match these.
# ``*.env.example`` uses endswith (suffix would be ``.example``).
_EXPORT_REDACT_NAMES = frozenset({
".cursorrules",
})
def _should_redact_export_file(path: Path) -> bool:
"""True when *path* is a text-ish file we should secret-scrub on export."""
name = path.name
if name in _EXPORT_REDACT_NAMES:
return True
if name.lower().endswith(".env.example"):
return True
return path.suffix.lower() in _EXPORT_REDACT_SUFFIXES
def _scrub_export_secrets(staged: Path) -> None:
"""Force-redact secret-shaped strings in a staged export tree.
Same ``agent.redact.redact_sensitive_text(..., force=True)`` pass used by
``hermes sessions export --redact``. Runs on the *staged copy only* so the
live profile is never rewritten. ``force=True`` ignores
``security.redact_secrets`` / ``HERMES_REDACT_SECRETS`` — share archives
must not emit raw keys even when the user has disabled live redaction.
Symlinks to text files are materialized into regular files when their
content changes, so redaction never follows a link back into the source
profile (``copytree(..., symlinks=True)``).
"""
from agent.redact import redact_sensitive_text
for path in staged.rglob("*"):
try:
is_link = path.is_symlink()
except OSError:
continue
if is_link:
# Skip broken links and symlinked directories.
try:
if not path.exists() or path.is_dir():
continue
except OSError:
continue
elif not path.is_file():
continue
if not _should_redact_export_file(path):
continue
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
redacted = redact_sensitive_text(text, force=True)
if redacted == text:
continue
if is_link:
path.unlink()
path.write_text(redacted, encoding="utf-8")
def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path:
"""Export a profile to a tar.gz archive.
``extra_files`` maps root-relative filenames (e.g. ``desktop.json``) to
text content staged into the archive alongside the profile's own files —
the desktop app uses it to bundle its appearance/interface overlay.
Returns the output file path.
Credential files (``auth.json``, ``.env``) are excluded, and secret-shaped
strings in staged text files are force-redacted before the archive is
written. Returns the output file path.
"""
import tempfile
@@ -1962,6 +2039,7 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str,
ignore=_default_export_ignore(profile_dir),
)
_stage_extras(staged)
_scrub_export_secrets(staged)
result = _make_profile_archive(base, tmpdir, "default")
return Path(result)
@@ -1976,6 +2054,7 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str,
ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),
)
_stage_extras(staged)
_scrub_export_secrets(staged)
result = _make_profile_archive(base, tmpdir, canon)
return Path(result)

View File

@@ -1,14 +1,27 @@
"""Tests for credential exclusion during profile export.
"""Tests for credential exclusion + secret scrubbing during profile export.
Profile exports should NEVER include auth.json or .env — these contain
API keys, OAuth tokens, and credential pool data. Users share exported
profiles; leaking credentials in the archive is a security issue.
Secret-shaped strings that sneak into skills / persona / memory text are
force-redacted in the staged archive (same pass as sessions --redact).
The live profile on disk must stay untouched.
"""
import tarfile
from hermes_cli.profiles import export_profile, _DEFAULT_EXPORT_EXCLUDE_ROOT
# Long enough to match agent.redact prefix patterns (sk- + 10+ chars).
_LEAKED_KEY = "sk-or-v1-reallyLongSecretKeyValue12345678"
def _patch_named_profile(monkeypatch, profiles_root, profile_dir):
monkeypatch.setattr("hermes_cli.profiles._get_profiles_root", lambda: profiles_root)
monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda n: profile_dir)
monkeypatch.setattr("hermes_cli.profiles.validate_profile_name", lambda n: None)
class TestCredentialExclusion:
@@ -31,9 +44,7 @@ class TestCredentialExclusion:
(profile_dir / "memories").mkdir()
(profile_dir / "memories" / "MEMORY.md").write_text("# Memories\n")
monkeypatch.setattr("hermes_cli.profiles._get_profiles_root", lambda: profiles_root)
monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda n: profile_dir)
monkeypatch.setattr("hermes_cli.profiles.validate_profile_name", lambda n: None)
_patch_named_profile(monkeypatch, profiles_root, profile_dir)
output = tmp_path / "export.tar.gz"
result = export_profile("testprofile", str(output))
@@ -46,3 +57,82 @@ class TestCredentialExclusion:
assert any("SOUL.md" in n for n in names), "SOUL.md should be in export"
assert not any("auth.json" in n for n in names), "auth.json must NOT be in export"
assert not any(".env" in n for n in names), ".env must NOT be in export"
class TestExportSecretScrub:
def test_named_profile_export_redacts_secrets_in_text(self, tmp_path, monkeypatch):
"""Leaked keys in skills / SOUL / memories must not leave the archive."""
profiles_root = tmp_path / "profiles"
profile_dir = profiles_root / "scrubme"
profile_dir.mkdir(parents=True)
soul = profile_dir / "SOUL.md"
soul.write_text(f"My key is {_LEAKED_KEY}\n")
skill_dir = profile_dir / "skills" / "demo"
skill_dir.mkdir(parents=True)
skill = skill_dir / "SKILL.md"
skill.write_text(
"---\nname: demo\ndescription: Demo.\n---\n"
f"Use OPENROUTER_API_KEY={_LEAKED_KEY}\n"
)
memories = profile_dir / "memories"
memories.mkdir()
memory = memories / "MEMORY.md"
memory.write_text(f"token {_LEAKED_KEY}\n")
(profile_dir / "config.yaml").write_text("model: gpt-4\n")
_patch_named_profile(monkeypatch, profiles_root, profile_dir)
result = export_profile("scrubme", str(tmp_path / "scrubme.tar.gz"))
with tarfile.open(result, "r:gz") as tf:
members = {
name: tf.extractfile(name).read().decode("utf-8")
for name in tf.getnames()
if name.endswith((".md", ".yaml"))
}
blob = "\n".join(members.values())
assert _LEAKED_KEY not in blob
assert any("SOUL.md" in n for n in members)
assert any("SKILL.md" in n for n in members)
assert any("MEMORY.md" in n for n in members)
# Live profile must keep the original plaintext.
assert _LEAKED_KEY in soul.read_text()
assert _LEAKED_KEY in skill.read_text()
assert _LEAKED_KEY in memory.read_text()
def test_export_redacts_through_symlink_without_touching_source(
self, tmp_path, monkeypatch
):
"""Symlinked skill text is redacted in the archive, source file stays put."""
profiles_root = tmp_path / "profiles"
profile_dir = profiles_root / "linkme"
profile_dir.mkdir(parents=True)
outside = tmp_path / "outside-skill.md"
outside.write_text(f"secret {_LEAKED_KEY}\n")
skill_dir = profile_dir / "skills" / "linked"
skill_dir.mkdir(parents=True)
link = skill_dir / "SKILL.md"
link.symlink_to(outside)
(profile_dir / "config.yaml").write_text("model: gpt-4\n")
_patch_named_profile(monkeypatch, profiles_root, profile_dir)
result = export_profile("linkme", str(tmp_path / "linkme.tar.gz"))
with tarfile.open(result, "r:gz") as tf:
skill_members = [n for n in tf.getnames() if n.endswith("SKILL.md")]
assert skill_members
archived = tf.extractfile(skill_members[0]).read().decode("utf-8")
assert _LEAKED_KEY not in archived
assert _LEAKED_KEY in outside.read_text()
assert link.is_symlink()