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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user