From 3b044261b6afe97277d48e7e75c65ce34e76e8a2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:44:41 -0700 Subject: [PATCH] fix(gateway): media denylist covers every profile's credentials, not just the launch home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under `gateway.multiplex_profiles` one process serves every `/profiles/*`, but `_media_delivery_denied_paths` expanded `_ROOT_CREDENTIAL_PATHS` only under the import-time `_HERMES_HOME` / `_HERMES_ROOT`. A `MEDIA:/profiles//.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 `/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 /.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). --- gateway/platforms/base.py | 21 +++++++++++-- tests/gateway/test_platform_base.py | 31 +++++++++++++++++++ .../docs/user-guide/multi-profile-gateways.md | 6 +++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 31c6bdf622..696547e097 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -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 ``/profiles/`` 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 ``/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:/profiles//.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()] diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 70b5ee2449..15d4abd2c5 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -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 ``/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 diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md index 9825f4d047..cad5cccdcd 100644 --- a/website/docs/user-guide/multi-profile-gateways.md +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -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.