From ff7233b81554cf09cb55eaa2da7f16311528a28e Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:05:00 +0530 Subject: [PATCH] fix(credential_files): apply the same exclusions to the symlink-safe mount copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _safe_skills_path() is the sibling of iter_skills_files(): when a symlink in skills/ forces a sanitized copy for mount-based backends (Docker/Singularity), it rglob-copied the whole tree — .hub, .curator_backups, node_modules and all. Prune EXCLUDED_SKILL_DIRS before descending, same rule as the sync generator, so the mounted copy never carries (or walks) the bookkeeping trees either. --- tests/tools/test_credential_files.py | 28 ++++++++++++++++++++++++++++ tools/credential_files.py | 23 +++++++++++++---------- 2 files changed, 41 insertions(+), 10 deletions(-) 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():