diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 085ea1d20e0..55f74f88f0c 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1066,12 +1066,48 @@ def _media_delivery_denied_paths() -> List[Path]: denied.append(home / sub) # The active Hermes profile and shared Hermes root both contain control # files and credentials. Only cache subdirectories under them are - # explicitly allowlisted above. + # explicitly allowlisted above (matched BEFORE this denylist in + # validate_media_delivery_path, so generated media still delivers). + # + # These are the per-file credential / secret stores that live at the + # HERMES_HOME root. The set mirrors the canonical read guard in + # agent/file_safety.py (get_read_block_error / build_write_denied_*) so the + # delivery (read/exfil) side can't trail the write side: a credential the + # agent is forbidden to write or read must also never be auto-attached to a + # chat reply. Enumerated explicitly per-file rather than denying the whole + # tree, so skills/, logs/, and ad-hoc agent-written files under ~/.hermes + # stay deliverable (see #32090, #34425). + _ROOT_CREDENTIAL_FILES = ( + ".env", + "auth.json", + "auth.lock", + "credentials", + "config.yaml", + # Anthropic PKCE / OAuth refresh credential store. + ".anthropic_oauth.json", + # Google Workspace skill: auto-refreshing OAuth token (mtime bumps + # every turn, which defeated the strict-mode recency window) plus the + # pending-exchange session/verifier file. + "google_token.json", + "google_oauth_pending.json", + os.path.join("auth", "google_oauth.json"), + # Webhook subscription HMAC secrets. + "webhook_subscriptions.json", + # Bitwarden Secrets Manager plaintext disk cache. + os.path.join("cache", "bws_cache.json"), + ) + # Directory trees whose every child is credential material. (MCP OAuth + # tokens under mcp-tokens/ are handled by the sibling targeted PR #37222; + # session/kanban SQLite stores by #41071 — kept out of this diff to avoid + # overlap.) + _ROOT_CREDENTIAL_DIRS = ( + "pairing", + ) for hermes_root in (_HERMES_HOME, _HERMES_ROOT): - denied.append(hermes_root / ".env") - denied.append(hermes_root / "auth.json") - denied.append(hermes_root / "credentials") - denied.append(hermes_root / "config.yaml") + for rel in _ROOT_CREDENTIAL_FILES: + denied.append(hermes_root / rel) + for rel in _ROOT_CREDENTIAL_DIRS: + denied.append(hermes_root / rel) return denied @@ -1190,9 +1226,12 @@ def validate_media_delivery_path(path: str) -> Optional[str]: return str(resolved) # 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. + # The denylist still blocks /etc, /proc, ~/.ssh, ~/.aws, and the + # credential/secret stores under the Hermes root (~/.hermes/.env, + # auth.json, .anthropic_oauth.json, google_token.json, pairing/, ...) — + # so the obvious prompt-injection / credential-exfil sites + # (``MEDIA:/etc/passwd``, ``MEDIA:~/.ssh/id_rsa``, + # ``MEDIA:~/.hermes/google_token.json``) remain rejected. if not _media_delivery_strict_mode(): if _path_under_denied_prefix(resolved): return None diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 3a4f85a5e41..60b69e000be 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -967,6 +967,105 @@ class TestMediaDeliveryDefaultMode: assert BasePlatformAdapter.validate_media_delivery_path(str(config_file)) is None + def test_denylist_blocks_google_token_default_mode(self, tmp_path, monkeypatch): + """Integration credentials at the HERMES_HOME root (google_token.json) + must never be deliverable, even though they aren't the historically + enumerated .env/auth.json/config.yaml files. Regression for a + refreshed google_token.json being auto-attached to a Slack reply + (#50912). + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + token = hermes_dir / "google_token.json" + token.write_text('{"access_token": "***", "refresh_token": "***"}') + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(token)) is None + + def test_denylist_blocks_google_token_even_when_freshly_refreshed(self, tmp_path, monkeypatch): + """The exploit was that the Google integration rewrites + google_token.json every turn, bumping its mtime to ~now, so the + strict-mode recency window (trust_recent_files) kept re-trusting it + and it re-sent on every reply. An explicit denylist entry must win + over recency trust. + """ + self._patch_roots(monkeypatch) # zero cache allowlist, strict mode on + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1") + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600") + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + token = hermes_dir / "google_token.json" + token.write_text('{"access_token": "***"}') # mtime = now → "recent" + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(token)) is None + + def test_denylist_blocks_pairing_directory_contents(self, tmp_path, monkeypatch): + """Files under ~/.hermes/pairing/ (platform pairing tokens) are + credential material and must not be deliverable. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + pairing = hermes_dir / "pairing" + pairing.mkdir(parents=True) + token = pairing / "telegram-approved.json" + token.write_text('{"approved": ["123"]}') + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(token)) is None + + def test_hermes_cache_still_delivers_under_denied_home(self, tmp_path, monkeypatch): + """The targeted credential denylist must not break legitimate cache + deliveries: a generated artifact under the allowlisted cache root is + matched before the denylist and still delivers. + """ + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + cache_dir = hermes_dir / "cache" / "documents" + cache_dir.mkdir(parents=True) + artifact = cache_dir / "report.pdf" + artifact.write_bytes(b"%PDF-1.4") + self._patch_roots(monkeypatch, cache_dir) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(artifact)) == str(artifact.resolve()) + + def test_denylist_blocks_non_cache_file_under_hermes_home(self, tmp_path, monkeypatch): + """A non-credential file the agent wrote directly under ~/.hermes + (not in a cache subdir) is still deliverable via recency trust — we + did NOT blanket-deny the tree (per #32090/#34425). This guards against + accidentally re-introducing the rejected whole-tree deny. + """ + self._patch_roots(monkeypatch) # strict mode on + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1") + monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600") + + fake_home = tmp_path / "home" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + artifact = hermes_dir / "adhoc_report.pdf" + artifact.write_bytes(b"%PDF-1.4") # fresh mtime + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + monkeypatch.setattr("gateway.platforms.base._HERMES_ROOT", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(artifact)) == str(artifact.resolve()) + 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