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:
@@ -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()]
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user