fix(gateway): media denylist covers every profile's credentials, not just the launch home

Under `gateway.multiplex_profiles` one process serves every `<root>/profiles/*`,
but `_media_delivery_denied_paths` expanded `_ROOT_CREDENTIAL_PATHS` only under
the import-time `_HERMES_HOME` / `_HERMES_ROOT`. A `MEDIA:<root>/profiles/<B>/.env`
(or auth.json, state.db, config.yaml, sessions/, mcp-tokens/) emitted in ANY
profile's turn — including B's own, whose HERMES_HOME override was never
consulted — passed validation and was natively uploaded to the chat. The ALLOW
side (`_profile_cache_roots`) already enumerated profiles at check time; the
DENY side did not.

`_credential_home_roots()` now yields the active `get_hermes_home()`, the shared
root and every `<root>/profiles/*` at check time (shared `_profile_dirs()` with
the allow side), and the denylist is built from that. Profile cache artifacts
and plain agent-written files under a profile stay deliverable.

Live repro (/tmp/mux_audit/fix-media-denylist/repro.py): before, all five of
profiles/B/{.env,auth.json,state.db,config.yaml,sessions/s1.json} validated as
deliverable while <root>/.env was blocked; after, all None, cache/images/gen.png
and report.pdf still deliverable.

No prior report. Write-side analogue: #107327 / #107335 (memo keying, different
mechanism — left as is).
This commit is contained in:
Teknium
2026-09-10 11:44:41 -07:00
parent 942973ae90
commit 3b044261b6
3 changed files with 54 additions and 4 deletions
+18 -3
View File
@@ -734,6 +734,8 @@ _CACHE_DIR_IMPORT_DEFAULTS = {
"VIDEO_CACHE_DIR": VIDEO_CACHE_DIR, "DOCUMENT_CACHE_DIR": DOCUMENT_CACHE_DIR,
"SCREENSHOT_CACHE_DIR": SCREENSHOT_CACHE_DIR}
# Launch-time homes: fine for the static ALLOW roots below (per-profile cache roots are
# enumerated at check time), never for the credential DENY side — see _credential_home_roots.
_HERMES_HOME = get_hermes_home()
_HERMES_ROOT = get_default_hermes_root()
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
@@ -795,11 +797,24 @@ def _profile_cache_roots() -> List[Path]:
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.
"""
return [p / "cache" / subdir for p in _profile_dirs() for subdir in _MEDIA_DELIVERY_CACHE_SUBDIRS]
def _profile_dirs() -> List[Path]:
"""Every ``<root>/profiles/<name>`` directory, read at check time."""
try:
profile_dirs = [p for p in (_HERMES_ROOT / "profiles").iterdir() if p.is_dir()]
return [p for p in (_HERMES_ROOT / "profiles").iterdir() if p.is_dir()]
except OSError:
return []
return [p / "cache" / subdir for p in profile_dirs for subdir in _MEDIA_DELIVERY_CACHE_SUBDIRS]
def _credential_home_roots() -> List[Path]:
"""Every Hermes home whose credential stores the denylist must cover: the ACTIVE home
(the per-turn HERMES_HOME override under ``gateway.multiplex_profiles``), the shared root
and every ``<root>/profiles/*``. Enumerated at check time like ``_profile_cache_roots`` on
the allow side — a denylist frozen at import covers only the launch profile, so a
``MEDIA:<root>/profiles/<other>/.env`` emitted in any profile's turn would upload it."""
return list(dict.fromkeys((get_hermes_home(), _HERMES_ROOT, *_profile_dirs())))
def _kanban_root() -> Path:
@@ -858,7 +873,7 @@ def _media_delivery_denied_paths() -> List[Path]:
home = Path(os.path.expanduser("~"))
return [*map(Path, _MEDIA_DELIVERY_DENIED_PREFIXES),
*(home / sub for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS),
*(r / rel for r in (_HERMES_HOME, _HERMES_ROOT) for rel in _ROOT_CREDENTIAL_PATHS),
*(r / rel for r in _credential_home_roots() for rel in _ROOT_CREDENTIAL_PATHS),
*_kanban_board_db_paths()]
+31
View File
@@ -706,6 +706,37 @@ class TestMediaDeliveryDefaultMode:
assert [rel for rel in denied if BasePlatformAdapter.validate_media_delivery_path(str(hermes_dir / rel))] == []
assert [rel for rel in allowed if not BasePlatformAdapter.validate_media_delivery_path(str(hermes_dir / rel))] == []
def test_denylist_covers_every_profile_home_not_just_the_launch_home(self, tmp_path, monkeypatch):
"""Multiplex: one process serves every ``<root>/profiles/*``. The credential denylist must
cover each profile's ``.env`` / ``auth.json`` / ``state.db`` / transcripts whether the emitting
turn is the launch (default) profile's or the secondary's own (HERMES_HOME override), while
the profile's cache artifacts and plain agent-written files stay deliverable."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
self._patch_roots(monkeypatch)
fake_home = tmp_path / "home"
hermes_root = fake_home / ".hermes"
profile_b = hermes_root / "profiles" / "beta"
monkeypatch.setenv("HOME", str(fake_home))
monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_root)
monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_root)
denied = [".env", "auth.json", "state.db", "state.db-wal", "config.yaml",
"sessions/20260101_abc.json", "mcp-tokens/server.json"]
allowed = ["cache/images/gen.png", "report.pdf"]
for rel in denied + allowed:
path = profile_b / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"SECRET=1\n")
for scope in (None, profile_b): # default profile's turn, then beta's own turn
token = set_hermes_home_override(scope)
try:
assert [rel for rel in denied if BasePlatformAdapter.validate_media_delivery_path(str(profile_b / rel))] == []
assert [rel for rel in allowed if not BasePlatformAdapter.validate_media_delivery_path(str(profile_b / rel))] == []
finally:
reset_hermes_home_override(token)
def test_strict_mode_envvar_restores_legacy_behavior(self, tmp_path, monkeypatch):
"""Setting HERMES_MEDIA_DELIVERY_STRICT=1 reactivates the older
allowlist+recency logic. A stale file outside the allowlist is
@@ -219,7 +219,11 @@ Kanban workers only ever see their own profile's secrets). Terminal settings
per profile on every routed turn: a profile that omits a terminal key gets the
documented default, never the launch profile's value, and a profile whose
`config.yaml`/`.env` cannot be parsed has terminal execution refused rather than
run under another profile's sandbox policy. Kanban,
run under another profile's sandbox policy. The media-delivery credential
guard (the denylist behind `MEDIA:` attachments — `.env`, `auth.json`,
`config.yaml`, `state.db`, session transcripts, OAuth token stores) covers every
profile under `profiles/`, so no profile's turn can attach another profile's
secrets or chat history to a reply. Kanban,
profile-scoped skills/memory/SOUL, and model routing all behave per-profile
exactly as they do with separate gateways.