fix(credential_files): apply the same exclusions to the symlink-safe mount copy
_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.
This commit is contained in:
@@ -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"
|
||||
|
||||
+13
-10
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user