diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index 45b0302f35c..a667b5a1e07 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -432,6 +432,20 @@ def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]: ) +def _reject_distribution_symlinks(staged: Path) -> None: + """Reject symlinks before reading or copying distribution files.""" + for entry in staged.rglob("*"): + if not entry.is_symlink(): + continue + try: + rel = entry.relative_to(staged) + except ValueError: + rel = entry + raise DistributionError( + f"Profile distributions cannot contain symlinks: {rel}" + ) + + # --------------------------------------------------------------------------- # Install # --------------------------------------------------------------------------- @@ -484,6 +498,7 @@ def plan_install( from hermes_cli import __version__ as hermes_version staged, provenance = _stage_source(source, workdir) + _reject_distribution_symlinks(staged) manifest = read_manifest(staged) if manifest is None: raise DistributionError( diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py index 46e00e33cac..cf27df91b69 100644 --- a/tests/hermes_cli/test_profile_distribution.py +++ b/tests/hermes_cli/test_profile_distribution.py @@ -74,6 +74,13 @@ def _make_staging_dir(root: Path, name: str = "src", *, manifest: DistributionMa return staged +def _symlink_file_or_skip(link: Path, target: Path) -> None: + try: + link.symlink_to(target) + except OSError as exc: + pytest.skip(f"symlinks unavailable in test environment: {exc}") + + # =========================================================================== # Manifest parsing # =========================================================================== @@ -473,6 +480,23 @@ class TestSecurity: if (plan.target_dir / ".env").exists(): assert "LEAKED" not in (plan.target_dir / ".env").read_text() + def test_install_rejects_symlinked_distribution_files(self, profile_env, tmp_path): + """Distribution install must not follow symlinks to local files.""" + staged = _make_staging_dir(profile_env, "src") + local_secret = tmp_path / "local-secret.txt" + local_secret.write_text("outside secret\n") + _symlink_file_or_skip( + staged / "skills" / "demo" / "leak.txt", + local_secret, + ) + + with pytest.raises(DistributionError, match="symlink"): + install_distribution(str(staged), name="clean") + + from hermes_cli.profiles import get_profile_dir + target = get_profile_dir("clean") + assert not (target / "skills" / "demo" / "leak.txt").exists() + # =========================================================================== # Install-time metadata (installed_at stamp) @@ -581,4 +605,3 @@ class TestErrorSurfaces: staged = _make_staging_dir(profile_env, "bad", manifest=mf) with pytest.raises((ValueError, DistributionError)): plan_install(str(staged), tmp_path / "work") -