diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 138246d654..eb41a99997 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -112,6 +112,34 @@ class TestSkillsDirectoryMount: # Symlink should NOT be present assert not (safe_path / "evil_link").exists() + def test_sanitized_copy_skips_bookkeeping_dirs(self, tmp_path): + """The symlink-safe copy is what gets mounted, so it must apply the + same EXCLUDED_SKILL_DIRS rule as the per-file sync path.""" + hermes_home = tmp_path / ".hermes" + skills_dir = hermes_home / "skills" + (skills_dir / "cat" / "myskill" / "references").mkdir(parents=True) + (skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill") + (skills_dir / "cat" / "myskill" / "references" / "api.md").write_text("ref") + for excluded in (".hub", ".curator_backups", "node_modules"): + junk = skills_dir / excluded / "vendored" + junk.mkdir(parents=True) + (junk / "blob.bin").write_bytes(b"\0" * 64) + # Force the sanitizing copy path. + secret = tmp_path / "secret.txt" + secret.write_text("TOP SECRET") + (skills_dir / "evil_link").symlink_to(secret) + + with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): + mounts = get_skills_directory_mount() + + safe_path = Path(mounts[0]["host_path"]) + assert safe_path != skills_dir + assert (safe_path / "cat" / "myskill" / "SKILL.md").exists() + assert (safe_path / "cat" / "myskill" / "references" / "api.md").exists() + assert not (safe_path / "evil_link").exists() + for excluded in (".hub", ".curator_backups", "node_modules"): + assert not (safe_path / excluded).exists(), excluded + def test_no_symlinks_returns_original_dir(self, tmp_path): """When no symlinks exist, the original dir is returned (no copy).""" hermes_home = tmp_path / ".hermes" diff --git a/tools/credential_files.py b/tools/credential_files.py index c4c0664754..631eb03710 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -326,16 +326,19 @@ def _safe_skills_path(skills_dir: Path) -> str: safe_dir = Path(tempfile.mkdtemp(prefix="hermes-skills-safe-")) _safe_skills_tempdir = safe_dir - for item in skills_dir.rglob("*"): - if item.is_symlink(): - continue - rel = item.relative_to(skills_dir) - target = safe_dir / rel - if item.is_dir(): - target.mkdir(parents=True, exist_ok=True) - elif item.is_file(): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(item), str(target)) + # Same exclusion rule as the per-file sync path (_iter_syncable_files): + # the sanitized copy is what gets mounted, so it must not carry the + # bookkeeping trees either. Prune before descending so a multi-GB + # .curator_backups is never even walked. + for dirpath, dirnames, filenames in os.walk(skills_dir): + dirnames[:] = sorted(d for d in dirnames if d not in EXCLUDED_SKILL_DIRS) + base = Path(dirpath) + (safe_dir / base.relative_to(skills_dir)).mkdir(parents=True, exist_ok=True) + for name in filenames: + item = base / name + if item.is_symlink() or not item.is_file(): + continue + shutil.copy2(str(item), str(safe_dir / item.relative_to(skills_dir))) def _cleanup(): if safe_dir.is_dir():