mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
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:
parent
2701ea2f0c
commit
90d25adc9e
2 changed files with 101 additions and 0 deletions
|
|
@ -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(","):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue