diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 8dd9fc8fdd4..616d5a73c85 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1010,9 +1010,47 @@ _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS = ( ) +# Canonical cache subdirectories that hold deliverable artifacts. Used both +# for the top-level safe roots above and to enumerate per-profile cache roots +# at check time (see _media_delivery_allowed_roots). +_MEDIA_DELIVERY_CACHE_SUBDIRS = ( + "images", + "audio", + "videos", + "documents", + "screenshots", +) + + +def _profile_cache_roots() -> List[Path]: + """Return per-profile canonical cache roots under the shared Hermes root. + + Profile gateways write generated artifacts to + ``/profiles//cache/{images,audio,...}``. The static safe-roots + list only covers the *active* HERMES_HOME's cache, so a gateway running at + the root (e.g. ``HERMES_HOME=/opt/data``) while the model emits a + profile-scoped path silently fails delivery. Enumerated dynamically at + check time so profiles created after startup are covered, and so the + resolved profile path is allowlisted *before* the ``/root`` system denylist + is consulted (which otherwise wins when HERMES_HOME is symlinked under a + denied prefix and $HOME is not that prefix). See issue #31733. + """ + roots: List[Path] = [] + profiles_dir = _HERMES_ROOT / "profiles" + try: + profile_dirs = [p for p in profiles_dir.iterdir() if p.is_dir()] + except OSError: + return roots + for profile_dir in profile_dirs: + for subdir in _MEDIA_DELIVERY_CACHE_SUBDIRS: + roots.append(profile_dir / "cache" / subdir) + return roots + + def _media_delivery_allowed_roots() -> List[Path]: """Return roots from which model-emitted local media may be delivered.""" roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS] + roots.extend(_profile_cache_roots()) extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "") for chunk in extra_roots.split(os.pathsep): for raw_root in chunk.split(","): diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index c2b7a2c8749..92348d565b1 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -1174,6 +1174,69 @@ class TestMediaDeliveryDefaultMode: assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None + def test_profile_scoped_cache_delivers_under_symlinked_root(self, tmp_path, monkeypatch): + """Reopened #31733: a profile gateway whose HERMES_HOME is symlinked + under a denied prefix (e.g. /opt/data -> /root/.hermes) emits + profile-scoped paths (``/profiles//cache/images/x.png``) + that resolve under ``/root``. ``$HOME`` is NOT that prefix, so the + root-home exception doesn't fire, and the top-level cache allowlist + doesn't cover the profile subdir — the file was silently dropped. + Per-profile cache roots must be allowlisted so it delivers. + """ + self._patch_roots(monkeypatch) # strict on, zero top-level cache roots + + # Stand-in for the literal /root deny prefix in the deployment. + denied_root = tmp_path / "root" + hermes_root = denied_root / ".hermes" + prof_cache = hermes_root / "profiles" / "myprof" / "cache" / "images" + prof_cache.mkdir(parents=True) + image = prof_cache / "gen.png" + image.write_bytes(b"\x89PNG\r\n\x1a\n") + + # $HOME is NOT the denied prefix (mirrors HOME=/opt/data/home). + fake_home = tmp_path / "opt" / "data" / "home" + fake_home.mkdir(parents=True) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(denied_root),), + ) + monkeypatch.setattr( + "gateway.platforms.base._HERMES_ROOT", hermes_root + ) + + assert ( + BasePlatformAdapter.validate_media_delivery_path(str(image)) + == str(image.resolve()) + ) + + def test_profile_scoped_credential_still_blocked_under_root(self, tmp_path, monkeypatch): + """The profile-cache allowlist must not un-block a credential sitting + directly in the profile dir (``profiles//auth.json``): it's not + under a cache subdir, so the credential denylist still rejects it. + """ + self._patch_roots(monkeypatch) + + denied_root = tmp_path / "root" + hermes_root = denied_root / ".hermes" + prof_dir = hermes_root / "profiles" / "myprof" + prof_dir.mkdir(parents=True) + cred = prof_dir / "auth.json" + cred.write_text("{}") + + fake_home = tmp_path / "opt" / "data" / "home" + fake_home.mkdir(parents=True) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(denied_root),), + ) + monkeypatch.setattr( + "gateway.platforms.base._HERMES_ROOT", hermes_root + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(cred)) is None + def test_other_users_home_still_blocked_for_nonroot(self, tmp_path, monkeypatch): """The exception only un-blocks the *running user's own* home. A non-root gateway ($HOME=/home/me) must not deliver another user's home