From 6c5cb2db4a0584c96d9f09c9699787b91ac07382 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 10 Aug 2026 16:21:17 -0500 Subject: [PATCH] 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. --- hermes_cli/profiles.py | 83 +++++++++++++++- .../test_profile_export_credentials.py | 98 ++++++++++++++++++- 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index c0cf2a9e8a..fbeb3f3fc3 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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) diff --git a/tests/hermes_cli/test_profile_export_credentials.py b/tests/hermes_cli/test_profile_export_credentials.py index bc69b72078..0ab6a4e23b 100644 --- a/tests/hermes_cli/test_profile_export_credentials.py +++ b/tests/hermes_cli/test_profile_export_credentials.py @@ -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()