Skip to content
Merged
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
45 changes: 40 additions & 5 deletions hermes_cli/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,25 @@ def _ignore(directory: str, names: List[str]) -> List[str]:
"logs", # gateway logs
})

# Allow-list for ``export_profile("default")``: when HERMES_HOME equals the
# cwd (Docker/custom deployments), the default profile home is the working
# directory and contains arbitrary user files that should NOT be bundled
# into the export. The set below identifies the *known Hermes profile
# artifacts* at the root of HERMES_HOME; everything else is excluded.
# Sensitive runtime infrastructure (``state.db``, ``logs/``, ``auth.*``,
# other profiles) is intentionally *not* in this list so the export stays
# a portable, credential-free snapshot of the user-facing surface
# (#58394). Add new artifacts here when introduced in ``hermes_constants``.
_DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({
# Configuration / persona
"config.yaml", "SOUL.md", "MEMORY.md", "USER.md", "todo.json",
"system_prompt.md", "AGENTS.md", "CLAUDE.md", ".cursorrules",
# User-facing skill, cron, and session artifacts
"skills", "cron", "scripts", "sessions",
# Plugin / memory surfaces (per-profile overrides live here)
"plugins", "memories", "knowledge", "preferences",
})

# Names that cannot be used as profile aliases
_RESERVED_NAMES = frozenset({
"hermes", "default", "test", "tmp", "root", "sudo",
Expand Down Expand Up @@ -1042,6 +1061,7 @@ def create_profile(
shutil.copytree(
source_dir,
profile_dir,
symlinks=True,
ignore=_clone_all_copytree_ignore(source_dir),
)
# Strip runtime files
Expand Down Expand Up @@ -1076,7 +1096,7 @@ def create_profile(
# same agent capabilities as the source profile.
source_skills = source_dir / "skills"
if source_skills.is_dir():
shutil.copytree(source_skills, profile_dir / "skills", dirs_exist_ok=True)
shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True)

# Clone memory and other subdirectory files
for relpath in _CLONE_SUBDIR_FILES:
Expand Down Expand Up @@ -1843,8 +1863,18 @@ def get_active_profile_name() -> str:
def _default_export_ignore(root_dir: Path):
"""Return an *ignore* callable for :func:`shutil.copytree`.

At the root level it excludes everything in ``_DEFAULT_EXPORT_EXCLUDE_ROOT``.
At all levels it excludes ``__pycache__``, sockets, and temp files.
Two-tier filtering:

* **Root-level allow-list** — only entries whose name appears in
``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as
an unrelated ``x11-dev/`` directory in a Docker deployment where
HERMES_HOME equals the cwd) is excluded. Blacklisting was tried
first and proved unable to anticipate every non-Hermes file the
user may have lying alongside HERMES_HOME (#58394).
* **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.
"""

def _ignore(directory: str, contents: list) -> set:
Expand All @@ -1856,9 +1886,12 @@ def _ignore(directory: str, contents: list) -> set:
# npm lockfiles can appear at root
elif entry in {"package.json", "package-lock.json"}:
ignored.add(entry)
# Root-level exclusions
# Root-level allow-list: drop everything that isn't a known
# Hermes profile artifact.
if Path(directory) == root_dir:
ignored.update(c for c in contents if c in _DEFAULT_EXPORT_EXCLUDE_ROOT)
ignored.update(
entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT
)
return ignored

return _ignore
Expand Down Expand Up @@ -1890,6 +1923,7 @@ def export_profile(name: str, output_path: str) -> Path:
shutil.copytree(
profile_dir,
staged,
symlinks=True,
ignore=_default_export_ignore(profile_dir),
)
result = shutil.make_archive(base, "gztar", tmpdir, "default")
Expand All @@ -1902,6 +1936,7 @@ def export_profile(name: str, output_path: str) -> Path:
shutil.copytree(
profile_dir,
staged,
symlinks=True,
ignore=lambda d, contents: _CREDENTIAL_FILES & set(contents),
)
result = shutil.make_archive(base, "gztar", tmpdir, canon)
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"Jigoooo@users.noreply.github.com": "Jigoooo", # PR #58474 salvage (auxiliary: fall back to token resolver when anthropic pool has no usable entry)
"root@vmi3351581.contaboserver.net": "ostravajih", # PR #58374 salvage (poolside: coerce integer finish_reason and tool_call id to strings)
"hello@sahil-shubham.in": "sahil-shubham", # PR #58448 salvage (whatsapp_cloud: honor documented WHATSAPP_CLOUD_ALLOWED_USERS / ALLOW_ALL_USERS in the DM intake gate)
"ahmet.tunc@gmail.com": "Ahmett101", # PR #58445 salvage (profiles: allowlist default-export roots + preserve symlinks)
"yingwaizhiying@gmail.com": "msh01", # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).
"danilo@falcao.org": "danilofalcao", # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)
"huanshan5195@users.noreply.github.com": "huanshan5195", # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level)
Expand Down
78 changes: 78 additions & 0 deletions tests/hermes_cli/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,84 @@ def test_export_default_excludes_pycache_at_any_depth(self, profile_env, tmp_pat

assert not any("__pycache__" in n for n in names)

def test_export_default_uses_allowlist_for_unrelated_dirs(self, profile_env, tmp_path):
"""Unrelated directories under HERMES_HOME are excluded by allow-list (#58394).

Docker/custom deployments often set HERMES_HOME to a working
directory that also contains unrelated user projects (``x11-dev/``,
etc.). The root-level allow-list filters those out so only known
Hermes artifacts end up in the archive. Replaces the old
exhaustive blacklist.
"""
default_dir = get_profile_dir("default")
(default_dir / "config.yaml").write_text("ok")
(default_dir / "SOUL.md").write_text("soul")
# Allowed subdirectory with content
(default_dir / "skills" / "demo").mkdir(parents=True)
(default_dir / "skills" / "demo" / "SKILL.md").write_text("hi")
# Unrelated directory — should NOT appear in the archive
unrelated = default_dir / "x11-dev" / "usr" / "lib"
unrelated.mkdir(parents=True)
(unrelated / "libXi.so").write_text("data")

output = tmp_path / "export" / "default.tar.gz"
output.parent.mkdir(parents=True, exist_ok=True)
result = export_profile("default", str(output))

with tarfile.open(str(result), "r:gz") as tf:
names = set(tf.getnames())

# Allowed artifacts present
assert any(n.endswith("config.yaml") for n in names)
assert any(n.endswith("SOUL.md") for n in names)
assert any(n.endswith("skills/demo/SKILL.md") for n in names)
# Unrelated artifact excluded
assert not any("x11-dev" in n for n in names)
assert not any("libXi.so" in n for n in names)

def test_export_default_handles_broken_symlinks(self, profile_env, tmp_path):
"""Broken symlinks inside allowed artifacts are preserved, not crashed (#58394).

``shutil.copytree``'s default is ``symlinks=False``, which follows
symlinks and crashes on broken ones. Use ``symlinks=True`` so stale
symlinks inside *allowed* artifacts (e.g. ``skills/``) survive as
symlinks; the link and its target are both retained.
"""
default_dir = get_profile_dir("default")
(default_dir / "config.yaml").write_text("ok")
# Place broken symlink *inside* the allowed ``skills/`` tree so the
# root-level allow-list passes the directory through; the
# symlinks=True flag must then preserve the link instead of
# following and crashing.
broken_dir = default_dir / "skills" / "with-broken-links"
broken_dir.mkdir(parents=True)
(broken_dir / "broken_link").symlink_to("/nonexistent/path")
# Valid symlink for comparison
(broken_dir / "valid_target.txt").write_text("real data")
(broken_dir / "valid_link").symlink_to(
broken_dir / "valid_target.txt"
)

output = tmp_path / "export" / "default.tar.gz"
output.parent.mkdir(parents=True, exist_ok=True)
result = export_profile("default", str(output))

assert result.exists()
with tarfile.open(str(result), "r:gz") as tf:
names = set(tf.getnames())
# Allowed artifact survived
assert any(n.endswith("config.yaml") for n in names)
# Broken symlink inside an allowed dir was preserved as a symlink
# (without crashing) — tar entry name recorded as the link path.
assert any(
"with-broken-links/broken_link" in n for n in names
), (
f"broken_link should survive; tarfile names: {sorted(names)[:30]}"
)
# Valid symlink + target also kept
assert any("valid_link" in n for n in names)
assert any("valid_target.txt" in n for n in names)

def test_import_default_without_name_raises(self, profile_env, tmp_path):
"""Importing a default export without --name gives clear guidance."""
default_dir = get_profile_dir("default")
Expand Down
Loading