diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d3960154688..91e360e7f4c 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -829,6 +829,13 @@ _HERMES_HOME = get_hermes_home() MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS" MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES" MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS" +# Strict mode toggles the original allowlist+recency path-validation behavior. +# Off by default — symmetric with inbound (we accept any document type the +# user uploads), and with the denylist still blocking obvious credential / +# system paths. Operators running public-facing gateways where prompt +# injection from one user could exfiltrate the host's secrets to that same +# user should set this to true. +MEDIA_DELIVERY_STRICT_ENV = "HERMES_MEDIA_DELIVERY_STRICT" MEDIA_DELIVERY_SAFE_ROOTS = ( IMAGE_CACHE_DIR, AUDIO_CACHE_DIR, @@ -918,6 +925,21 @@ def _media_delivery_recency_seconds() -> float: return float(_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS) +def _media_delivery_strict_mode() -> bool: + """Return True when path validation should require allowlist/recency match. + + Off by default. In non-strict mode, ``validate_media_delivery_path`` + accepts any existing regular file that isn't under the credential / + system-path denylist — restoring the pre-#29523 behavior for the + single-user case. Strict mode preserves the original + allowlist+recency-window logic for operators running public-facing + gateways where prompt injection from one user shouldn't be able to + exfiltrate the host's secrets to that same user. + """ + raw = os.environ.get(MEDIA_DELIVERY_STRICT_ENV, "0").strip().lower() + return raw in ("1", "true", "yes", "on") + + def _media_delivery_denied_paths() -> List[Path]: """Return absolute denylist paths under which delivery is never allowed.""" denied = [Path(p) for p in _MEDIA_DELIVERY_DENIED_PREFIXES] @@ -972,10 +994,22 @@ def _path_is_within(path: Path, root: Path) -> bool: def validate_media_delivery_path(path: str) -> Optional[str]: """Return a safe absolute file path for native media delivery, else None. - MEDIA tags and bare local paths in model output are untrusted text. Only - existing regular files under Hermes-managed media caches, or roots the - operator explicitly allowlists, may be uploaded as native attachments. - Symlinks are resolved before the containment check. + Default mode (single-user / private gateway): accept any existing regular + file that isn't under the credential / system-path denylist + (``_MEDIA_DELIVERY_DENIED_PREFIXES`` + ``~/.ssh``, ``~/.aws``, etc.). + This matches the symmetry of inbound delivery — Telegram/Discord/Slack + will hand the agent any file the user uploads, and the agent can hand + back any file that isn't a credential. + + Strict mode (opt-in via ``gateway.strict`` in ``config.yaml`` or + ``HERMES_MEDIA_DELIVERY_STRICT=1``): the file MUST live under a + Hermes-managed cache, under an operator-allowlisted root + (``HERMES_MEDIA_ALLOW_DIRS``), or be freshly produced inside the + configured recency window. Suitable for public-facing bots where + prompt injection from one user shouldn't be able to exfiltrate the + host's secrets to that same user. + + Symlinks are resolved before any containment / denylist check. """ if not path: return None @@ -999,6 +1033,8 @@ def validate_media_delivery_path(path: str) -> Optional[str]: if not resolved.is_file(): return None + # Cache / operator allowlist is always honored — these are unconditionally + # trusted regardless of mode. for root in _media_delivery_allowed_roots(): try: resolved_root = root.expanduser().resolve(strict=False) @@ -1007,9 +1043,18 @@ def validate_media_delivery_path(path: str) -> Optional[str]: if _path_is_within(resolved, resolved_root): return str(resolved) - # Outside the cache/operator allowlist: fall back to recency-based trust - # for files the agent has just produced (e.g. ``pandoc -o /tmp/report.pdf`` - # or ``write_file("/home/user/report.pdf", ...)``). System paths and + # Non-strict mode (default): accept anything not on the denylist. + # The denylist still blocks /etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env, + # ~/.hermes/auth.json, etc. — so the obvious prompt-injection sites + # (``MEDIA:/etc/passwd``, ``MEDIA:~/.ssh/id_rsa``) remain rejected. + if not _media_delivery_strict_mode(): + if _path_under_denied_prefix(resolved): + return None + return str(resolved) + + # Strict mode: fall back to recency-based trust for freshly-produced + # files (e.g. ``pandoc -o /tmp/report.pdf`` or + # ``write_file("/home/user/report.pdf", ...)``). System paths and # credential locations remain blocked even when "recent" — see # ``_MEDIA_DELIVERY_DENIED_PREFIXES`` for the denylist. window = _media_delivery_recency_seconds() diff --git a/gateway/run.py b/gateway/run.py index 057d15cab91..8dc2fb3959c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -932,9 +932,14 @@ if _config_path.exists(): _redact = _security_cfg.get("redact_secrets") if _redact is not None: os.environ["HERMES_REDACT_SECRETS"] = str(_redact).lower() - # Gateway settings (media delivery allowlist + recency trust) + # Gateway settings (media delivery allowlist + recency trust + strict mode) _gateway_cfg = _cfg.get("gateway", {}) if isinstance(_gateway_cfg, dict): + _strict = _gateway_cfg.get("strict") + if _strict is not None: + os.environ["HERMES_MEDIA_DELIVERY_STRICT"] = ( + "1" if _strict else "0" + ) _allow_dirs = _gateway_cfg.get("media_delivery_allow_dirs") if _allow_dirs: if isinstance(_allow_dirs, str): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 96fb77b4c49..ff1f988f69d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1806,6 +1806,21 @@ DEFAULT_CONFIG = { # Gateway settings — control how messaging platforms (Telegram, Discord, # Slack, etc.) deliver agent-produced files as native attachments. "gateway": { + # When false (default), any file path the agent emits is delivered + # as a native attachment as long as it isn't under the credential / + # system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env, + # auth.json, etc.). This matches the symmetry of inbound delivery + # — we accept any document type the user uploads, and the agent + # can hand back any file that isn't a credential. + # + # When true, fall back to the older allowlist+recency-window + # behavior: files must live under the Hermes cache, under + # ``media_delivery_allow_dirs``, or be freshly produced inside the + # ``trust_recent_files_seconds`` window. Recommended for + # public-facing gateways where prompt injection from one user + # shouldn't be able to exfiltrate the host's secrets to that same + # user. Bridged to HERMES_MEDIA_DELIVERY_STRICT. + "strict": False, # Extra directories from which model-emitted bare file paths may be # uploaded as native gateway attachments. Files inside the Hermes # cache (~/.hermes/cache/{documents,images,audio,video,screenshots}) @@ -1813,7 +1828,7 @@ DEFAULT_CONFIG = { # (project dirs, scratch dirs, mounted shares). Accepts a list of # absolute paths or a single os.pathsep-separated string. Bridged # to HERMES_MEDIA_ALLOW_DIRS at gateway startup. Tilde paths are - # expanded. + # expanded. Honored in both default and strict mode. "media_delivery_allow_dirs": [], # When true, files whose mtime is within ``trust_recent_files_seconds`` # of "now" are trusted for native delivery even outside the cache / @@ -1821,10 +1836,12 @@ DEFAULT_CONFIG = { # PDFs the agent writes into a working directory. System paths # (/etc, /proc, ~/.ssh, ~/.aws, etc.) remain blocked regardless. # Disable to fall back to pure-allowlist mode. Bridged to - # HERMES_MEDIA_TRUST_RECENT_FILES. + # HERMES_MEDIA_TRUST_RECENT_FILES. Only consulted when ``strict`` + # is true; in default mode the denylist alone gates delivery. "trust_recent_files": True, # Recency window in seconds. 600 (10 min) comfortably covers a # multi-tool agent turn. Bridged to HERMES_MEDIA_TRUST_RECENT_SECONDS. + # Only consulted when ``strict`` is true. "trust_recent_files_seconds": 600, }, diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index b7d96d4dc3e..8be8feb2a46 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -368,6 +368,11 @@ class TestMediaDeliveryPathValidation: "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", tuple(roots), ) + # All tests in this class cover strict-mode behavior (allowlist + + # recency window + denylist). Force strict on so they keep + # exercising the legacy path even though the public default + # flipped to off in 2026-05. + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1") # Disable recency-based trust by default so the original allowlist # tests continue to exercise the strict-allowlist path. Tests that # specifically cover recency trust re-enable it themselves. @@ -536,6 +541,149 @@ class TestMediaDeliveryPathValidation: assert out == [str(fresh.resolve())] +class TestMediaDeliveryDefaultMode: + """Default (non-strict) mode — denylist gates delivery, nothing else. + + Symmetric with inbound delivery: Telegram/Discord/Slack accept any + document type the user uploads, and the agent can hand back any file + that isn't a credential. Strict mode is opt-in for operators running + public-facing gateways. + """ + + def _patch_roots(self, monkeypatch, *roots): + # Empty cache allowlist so the only positive path through + # validate_media_delivery_path in these tests is the + # default-mode "anything not denied" branch. + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + tuple(roots), + ) + # Pin strict OFF — the public default. Tests that exercise the + # strict path live in TestMediaDeliveryPathValidation. + monkeypatch.delenv("HERMES_MEDIA_DELIVERY_STRICT", raising=False) + monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False) + + def test_accepts_stale_file_outside_allowlist(self, tmp_path, monkeypatch): + """The motivating case — agent says ``MEDIA:/home/user/notes.md`` + for an .md it has been working with for hours. Strict mode would + reject this (outside allowlist, outside recency window). Default + mode delivers it. + """ + self._patch_roots(monkeypatch) + + notes = tmp_path / "notes.md" + notes.write_text("# Old notes\n") + old_mtime = time.time() - 7200 # 2 hours ago — far outside any window + os.utime(notes, (old_mtime, old_mtime)) + + assert BasePlatformAdapter.validate_media_delivery_path(str(notes)) == str(notes.resolve()) + + def test_accepts_any_extension_not_on_denylist(self, tmp_path, monkeypatch): + """No extension allowlist — .md, .txt, .json, .py all deliver.""" + self._patch_roots(monkeypatch) + + for name in ("report.md", "log.txt", "data.json", "script.py", "blob.bin"): + f = tmp_path / name + f.write_bytes(b"x") + assert BasePlatformAdapter.validate_media_delivery_path(str(f)) == str(f.resolve()) + + def test_denylist_still_blocks_credentials(self, tmp_path, monkeypatch): + """Default mode is permissive but not naive — credential paths + remain blocked. Simulate $HOME so ~/.ssh resolves into tmp_path. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + ssh_dir = fake_home / ".ssh" + ssh_dir.mkdir(parents=True) + secret = ssh_dir / "id_rsa" + secret.write_bytes(b"-----BEGIN ...") + monkeypatch.setenv("HOME", str(fake_home)) + + assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None + + def test_denylist_blocks_system_prefixes(self, tmp_path, monkeypatch): + """Files under /etc, /proc, /sys, /root, /boot, /var/{log,lib,run} + are denied. We construct the test by patching the denylist root + to a tmp dir so we don't need to read /etc. + """ + self._patch_roots(monkeypatch) + + fake_etc = tmp_path / "fake-etc" + fake_etc.mkdir() + secret = fake_etc / "shadow" + secret.write_bytes(b"root:!:0:0::/root:/bin/sh") + + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_etc),), + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None + + def test_denylist_blocks_hermes_credentials(self, tmp_path, monkeypatch): + """~/.hermes/.env and ~/.hermes/auth.json stay blocked even in + default mode. They live under $HOME (not the system prefix list) + so this exercises the home-relative denied paths. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + env_file = hermes_dir / ".env" + env_file.write_text("OPENAI_API_KEY=sk-...") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._HERMES_HOME", + hermes_dir, + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None + + 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 + rejected. + """ + self._patch_roots(monkeypatch) + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1") + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0") + + stale = tmp_path / "old.pdf" + stale.write_bytes(b"%PDF-1.4") + old_mtime = time.time() - 7200 + os.utime(stale, (old_mtime, old_mtime)) + + assert BasePlatformAdapter.validate_media_delivery_path(str(stale)) is None + + def test_strict_mode_truthy_aliases(self, monkeypatch, tmp_path): + """``HERMES_MEDIA_DELIVERY_STRICT=true|yes|on|1`` all enable strict mode.""" + self._patch_roots(monkeypatch) + from gateway.platforms.base import _media_delivery_strict_mode + + for raw in ("1", "true", "TRUE", "yes", "on"): + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", raw) + assert _media_delivery_strict_mode() is True + + for raw in ("0", "false", "no", "off", ""): + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", raw) + assert _media_delivery_strict_mode() is False + + def test_filter_passes_default_files_through(self, tmp_path, monkeypatch): + """End-to-end: filter_local_delivery_paths accepts a stale .md in + default mode where strict mode would drop it. + """ + self._patch_roots(monkeypatch) + + notes = tmp_path / "notes.md" + notes.write_text("# old\n") + os.utime(notes, (time.time() - 86400, time.time() - 86400)) + + out = BasePlatformAdapter.filter_local_delivery_paths([str(notes)]) + assert out == [str(notes.resolve())] + + # --------------------------------------------------------------------------- # should_send_media_as_audio # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index eeb740f8f62..82421785265 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -234,9 +234,12 @@ async def test_streaming_delivery_blocks_media_path_outside_allowed_roots(tmp_pa "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", (allowed_root,), ) - # This test exercises the strict-allowlist path; disable recency trust so - # the freshly-written tmp_path file is not auto-accepted by the trust - # window. (Recency trust is covered separately in test_platform_base.py.) + # This test exercises the strict-allowlist path; force strict mode on + # and disable recency trust so the freshly-written tmp_path file is not + # auto-accepted by the trust window. (Recency trust is covered separately + # in test_platform_base.py. The public default flipped to non-strict in + # 2026-05; this test pins strict on explicitly.) + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1") monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0") adapter = SimpleNamespace( name="test", diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 922a7d7bdc2..1288162587e 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -378,9 +378,12 @@ class TestSendMessageTool: ) def test_media_tag_outside_allowed_roots_is_not_sent(self, tmp_path, monkeypatch): - # This test exercises the strict-allowlist path; disable recency trust - # so the freshly-written tmp_path file is not auto-accepted by the - # trust window. (Recency trust is covered in test_platform_base.py.) + # This test exercises the strict-allowlist path; force strict mode on + # and disable recency trust so the freshly-written tmp_path file is + # not auto-accepted by the trust window. (Recency trust is covered + # in test_platform_base.py. The public default flipped to non-strict + # in 2026-05; this test pins strict on explicitly.) + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "1") monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0") config, telegram_cfg = _make_config() secret = tmp_path / "secret.pdf" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 4494fbd0cf9..dab83b854ed 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -139,7 +139,7 @@ SEND_MESSAGE_SCHEMA = { }, "message": { "type": "string", - "description": "The message text to send. To send an image or file, include MEDIA: for a file under a Hermes media cache or HERMES_MEDIA_ALLOW_DIRS — the platform will deliver it as a native media attachment." + "description": "The message text to send. To send an image or file, include MEDIA: (e.g. 'MEDIA:/tmp/report.pdf') in the message — the platform will deliver it as a native media attachment." } }, "required": []