diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 9f681037f2..389cc7933b 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -2128,7 +2128,8 @@ def _inside_git_checkout(path: Path) -> bool: """ try: resolved = path.resolve() - except OSError: + except (OSError, RuntimeError): + # RuntimeError: symlink loops on Python <= 3.12; fail closed either way. return True return any( (candidate / ".git").exists() for candidate in (resolved, *resolved.parents) @@ -2147,9 +2148,12 @@ def _profile_export_directory() -> Path: # not put the automatic archive under that tree; use a sibling store and # fall back to the OS temp directory only for the unusual case where the # user's home itself is a checkout (e.g. a dotfiles repo). + # Per-uid temp name: a fixed /tmp/hermes-profile-exports is a predictable + # shared path another local user could pre-create (or symlink) before us. + uid_suffix = f"-{os.getuid()}" if hasattr(os, "getuid") else "" candidates = ( Path.home() / ".hermes-profile-exports", - Path(tempfile.gettempdir()) / "hermes-profile-exports", + Path(tempfile.gettempdir()) / f"hermes-profile-exports{uid_suffix}", ) for candidate in candidates: if not _inside_git_checkout(candidate): @@ -2159,8 +2163,8 @@ def _profile_export_directory() -> Path: # would not stop a scripted export from recreating it. raise ValueError( "No safe automatic export destination: every candidate directory is " - "inside a Git checkout. Pass an explicit output path outside the " - "checkout (e.g. -o /path/outside/repo/profile.tar.gz)." + "inside a Git checkout. Provide an explicit output path outside the " + "checkout (CLI: -o /path/outside/repo/profile.tar.gz)." ) @@ -2176,6 +2180,20 @@ def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Pa validate_profile_name(canon) export_dir = _profile_export_directory() export_dir.mkdir(parents=True, exist_ok=True) + # exist_ok=True would silently accept a directory (or symlink) another + # local user pre-created at a predictable path; refuse to write a + # secret-bearing archive anywhere we don't own. + if export_dir.is_symlink(): + raise ValueError( + f"Export directory {export_dir} is a symlink; refusing to write " + "a profile archive through it. Provide an explicit output path." + ) + if hasattr(os, "getuid") and export_dir.stat().st_uid != os.getuid(): + raise ValueError( + f"Export directory {export_dir} is owned by another user; " + "refusing to write a profile archive there. Provide an explicit " + "output path." + ) stamp = timestamp or time.strftime("%Y%m%d-%H%M%S") return export_dir / f"{canon}-{stamp}.tar.gz" diff --git a/tests/hermes_cli/test_profile_export_default_path.py b/tests/hermes_cli/test_profile_export_default_path.py index af60becc1d..6795cd5ec4 100644 --- a/tests/hermes_cli/test_profile_export_default_path.py +++ b/tests/hermes_cli/test_profile_export_default_path.py @@ -212,3 +212,17 @@ def test_every_candidate_inside_a_checkout_fails_closed( with pytest.raises(ValueError, match="No safe automatic export destination"): profiles.get_profile_export_path("default") + + +def test_export_dir_symlink_is_rejected(tmp_path, monkeypatch, profiles): + """A pre-created symlink at the managed export path (predictable-path + attack on shared hosts) must be refused, not silently followed.""" + default_home = tmp_path / ".hermes" + default_home.mkdir() + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (default_home / "profile-exports").symlink_to(elsewhere) + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + + with pytest.raises(ValueError, match="symlink"): + profiles.get_profile_export_path("default") diff --git a/website/docs/user-guide/profile-distributions.md b/website/docs/user-guide/profile-distributions.md index 718b799d20..14bbba02f7 100644 --- a/website/docs/user-guide/profile-distributions.md +++ b/website/docs/user-guide/profile-distributions.md @@ -635,7 +635,11 @@ working directory. This keeps routine exports out of source checkouts and prevents a generated profile snapshot from being mistaken for a repository source file. If the Hermes home itself lives inside a Git checkout (some Docker/custom deployments), the archive goes to `~/.hermes-profile-exports/` -instead — never into the checkout. An explicit `-o` path is still honored +or, failing that, a per-user directory under the OS temp dir — never into +the checkout. If no safe automatic location exists at all (every candidate +is inside a Git checkout), the export refuses with "No safe automatic +export destination" and you must pass `-o` with a path outside the +checkout. An explicit `-o` path is still honored when you intentionally choose where to save the archive. Or from a shell, same machinery: