{"repo":"NousResearch/hermes-agent","public_id":"31733","display_label":"Gateway MEDIA delivery safe roots for cache paths","summary":"These issues and pull requests address a problem where cached media files (e.g., under cache/images or symlinked HERMES_HOME paths) are not recognized as safe roots for MEDIA delivery in the gateway. The work involves adding canonical cache directory paths to MEDIA_DELIVERY_SAFE_ROOTS and improving diagnostics for related rejections.","subsystem":"gateway","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34485},"resolution":{"state":"partially_resolved","by":"pr-54060"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T08:07:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.23022222222222222},"fix_edges":[{"issue_number":31733,"pr_number":34485,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":31733,"pr_number":54060,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":33549,"pr_number":34485,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33549,"pr_number":54060,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":31733,"title":"Generated images under cache/images may not deliver via Telegram MEDIA, while image_cache works","url":null,"state":"closed"},{"kind":"issue","number":33549,"title":"Canonical media cache directories should be safe delivery roots","url":null,"state":"closed"},{"kind":"pr","number":31764,"title":"fix(gateway): add canonical cache paths to MEDIA_DELIVERY_SAFE_ROOTS","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34485","number":34485},{"kind":"pr","value":"#31733","number":31733},{"kind":"pr","value":"#54060","number":54060}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33238,"title":"fix(gateway): add canonical cache paths to MEDIA_DELIVERY_SAFE_ROOTS","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34485","number":34485},{"kind":"pr","value":"#31733","number":31733},{"kind":"pr","value":"#54060","number":54060}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33251,"title":"fix(gateway): diagnosable MEDIA delivery + canonical cache roots (#31733)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e28a668b40d3888fb69a624ef5fcb4dd59c9e5ff","number":0},{"kind":"pr","value":"#34485","number":34485},{"kind":"pr","value":"#31733","number":31733}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":33550,"title":"fix(gateway): allow canonical media cache delivery roots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34485","number":34485},{"kind":"pr","value":"#31733","number":31733},{"kind":"pr","value":"#54060","number":54060}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33586,"title":"fix(gateway): add canonical cache paths to MEDIA_DELIVERY_SAFE_ROOTS","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34485","number":34485},{"kind":"pr","value":"#31733","number":31733},{"kind":"pr","value":"#54060","number":54060}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":33901,"title":"fix: add cache subdirectories to MEDIA delivery safe roots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34485","number":34485},{"kind":"pr","value":"#31733","number":31733},{"kind":"pr","value":"#54060","number":54060}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34485,"title":"fix(gateway): diagnosable MEDIA rejections + canonical cache roots + null-path guard","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/gateway/test_platform_base.py"]},{"kind":"pr","number":54060,"title":"fix(gateway): deliver profile-scoped cache media on symlinked HERMES_HOME","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/gateway/test_platform_base.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1010,9 +1010,47 @@ def cache_video_from_bytes(data: bytes, ext: str = \".mp4\") -> str:\n )\n \n \n+# Canonical cache subdirectories that hold deliverable artifacts. Used both\n+# for the top-level safe roots above and to enumerate per-profile cache roots\n+# at check time (see _media_delivery_allowed_roots).\n+_MEDIA_DELIVERY_CACHE_SUBDIRS = (\n+    \"images\",\n+    \"audio\",\n+    \"videos\",\n+    \"documents\",\n+    \"screenshots\",\n+)\n+\n+\n+def _profile_cache_roots() -> List[Path]:\n+    \"\"\"Return per-profile canonical cache roots under the shared Hermes root.\n+\n+    Profile gateways write generated artifacts to\n+    ``<root>/profiles/<name>/cache/{images,audio,...}``. The static safe-roots\n+    list only covers the *active* HERMES_HOME's cache, so a gateway running at\n+    the root (e.g. ``HERMES_HOME=/opt/data``) while the model emits a\n+    profile-scoped path silently fails delivery. Enumerated dynamically at\n+    check time so profiles created after startup are covered, and so the\n+    resolved profile path is allowlisted *before* the ``/root`` system denylist\n+    is consulted (which otherwise wins when HERMES_HOME is symlinked under a\n+    denied prefix and $HOME is not that prefix). See issue #31733.\n+    \"\"\"\n+    roots: List[Path] = []\n+    profiles_dir = _HERMES_ROOT / \"profiles\"\n+    try:\n+        profile_dirs = [p for p in profiles_dir.iterdir() if p.is_dir()]\n+    except OSError:\n+        return roots\n+    for profile_dir in profile_dirs:\n+        for subdir in _MEDIA_DELIVERY_CACHE_SUBDIRS:\n+            roots.append(profile_dir / \"cache\" / subdir)\n+    return roots\n+\n+\n def _media_delivery_allowed_roots() -> List[Path]:\n     \"\"\"Return roots from which model-emitted local media may be delivered.\"\"\"\n     roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS]\n+    roots.extend(_profile_cache_roots())\n     extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, \"\")\n     for chunk in extra_roots.split(os.pathsep):\n         for raw_root in chunk.split(\",\"):"]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -1174,6 +1174,69 @@ def test_root_home_hermes_env_still_blocked(self, tmp_path, monkeypatch):\n \n         assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None\n \n+    def test_profile_scoped_cache_delivers_under_symlinked_root(self, tmp_path, monkeypatch):\n+        \"\"\"Reopened #31733: a profile gateway whose HERMES_HOME is symlinked\n+        under a denied prefix (e.g. /opt/data -> /root/.hermes) emits\n+        profile-scoped paths (``<root>/profiles/<name>/cache/images/x.png``)\n+        that resolve under ``/root``. ``$HOME`` is NOT that prefix, so the\n+        root-home exception doesn't fire, and the top-level cache allowlist\n+        doesn't cover the profile subdir — the file was silently dropped.\n+        Per-profile cache roots must be allowlisted so it delivers.\n+        \"\"\"\n+        self._patch_roots(monkeypatch)  # strict on, zero top-level cache roots\n+\n+        # Stand-in for the literal /root deny prefix in the deployment.\n+        denied_root = tmp_path / \"root\"\n+        hermes_root = denied_root / \".hermes\"\n+        prof_cache = hermes_root / \"profiles\" / \"myprof\" / \"cache\" / \"images\"\n+        prof_cache.mkdir(parents=True)\n+        image = prof_cache / \"gen.png\"\n+        image.write_bytes(b\"\\x89PNG\\r\\n\\x1a\\n\")\n+\n+        # $HOME is NOT the denied prefix (mirrors HOME=/opt/data/home).\n+        fake_home = tmp_path / \"opt\" / \"data\" / \"home\"\n+        fake_home.mkdir(parents=True)\n+        monkeypatch.setenv(\"HOME\", str(fake_home))\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES\",\n+            (str(denied_root),),\n+        )\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base._HERMES_ROOT\", hermes_root\n+        )\n+\n+        assert (\n+            BasePlatformAdapter.validate_media_delivery_path(str(image))\n+            == str(image.resolve())\n+        )\n+\n+    def test_profile_scoped_credential_still_blocked_under_root(self, tmp_path, monkeypatch):\n+        \"\"\"The profile-cache allowlist must not un-block a credential sitting\n+        directly in the profile dir (``profiles/<name>/auth.json``): it's not\n+        under a cache subdir, so the credential denylist still rejects it.\n+        \"\"\"\n+        self._patch_roots(monkeypatch)\n+\n+        denied_root = tmp_path / \"root\"\n+        hermes_root = denied_root / \".hermes\"\n+        prof_dir = hermes_root / \"profiles\" / \"myprof\"\n+        prof_dir.mkdir(parents=True)\n+        cred = prof_dir / \"auth.json\"\n+        cred.write_text(\"{}\")\n+\n+        fake_home = tmp_path / \"opt\" / \"data\" / \"home\"\n+        fake_home.mkdir(parents=True)\n+        monkeypatch.setenv(\"HOME\", str(fake_home))\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES\",\n+            (str(denied_root),),\n+        )\n+        monkeypatch.setattr(\n+            \"gateway.platforms.base._HERMES_ROOT\", hermes_root\n+        )\n+\n+        assert BasePlatformAdapter.validate_media_delivery_path(str(cred)) is None\n+\n     def test_other_users_home_still_blocked_for_nonroot(self, tmp_path, monkeypatch):\n         \"\"\"The exception only un-blocks the *running user's own* home. A\n         non-root gateway ($HOME=/home/me) must not deliver another user's home\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}