fix(gateway): deliver profile-scoped cache media on symlinked HERMES_HOME (#54060)

Generated images under a profile gateway's cache (profiles/<name>/cache/
images/...) were silently dropped from Telegram/Discord delivery when
HERMES_HOME is symlinked under a denied prefix (e.g. /opt/data ->
/root/.hermes) and $HOME is not that prefix. The resolved path lands
under /root (a system denylist prefix), the root-home exception only
fires when the denied prefix IS $HOME, and the static safe-roots list
only covers the active HERMES_HOME's top-level cache — not per-profile
cache dirs. Both gates fail, so validate_media_delivery_path returns
None and the gateway logs 'Skipping unsafe MEDIA directive path'.

_media_delivery_allowed_roots() now also enumerates per-profile cache
roots (<root>/profiles/*/cache/{images,audio,videos,documents,
screenshots}) at check time. Allowlist match runs before the denylist,
so the profile artifact delivers regardless of the /root interaction;
profile-dir credentials (auth.json) stay blocked since they aren't
under a cache subdir.

Reopened regression of #34485/#38108, neither of which covered the
profile-scoped symlink case. Fixes #31733.
This commit is contained in:
Teknium 2026-06-28 01:07:28 -07:00 committed by GitHub
parent 2701ea2f0c
commit 90d25adc9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 101 additions and 0 deletions

View file

@ -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
``<root>/profiles/<name>/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(","):

View file

@ -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 (``<root>/profiles/<name>/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/<name>/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