mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-05 07:41:39 +00:00
fix(gateway): allow native delivery of freshly-produced agent files (#32060)
The gateway's media delivery allowlist required files live inside
`~/.hermes/cache/{documents,images,...}`, which is the wrong shape for
real agent usage. Agents naturally produce artifacts via terminal tools
(`pandoc -o /tmp/report.pdf`, `matplotlib savefig`, etc.) or
write_file into project directories — these never land under the cache.
Result: users got a raw file path in chat instead of an attachment.
This is doubly bad in deployment shapes where the cache directories
aren't writable by the agent at all: Hermes running in Docker with a
read-only mount, or with a Docker/Modal/SSH terminal backend whose
filesystem isn't the gateway host's filesystem.
Layered trust model:
1. Cache-dir allowlist (unchanged) — Hermes-managed roots always trusted.
2. Operator allowlist — `HERMES_MEDIA_ALLOW_DIRS` env var, now also
surfaced as `gateway.media_delivery_allow_dirs` in config.yaml.
3. Recency-based trust (new, default on) — files whose mtime is within
`gateway.trust_recent_files_seconds` (default 600s) of "now" are
trusted even outside the cache/operator allowlist. Old host files
(`/etc/passwd`, `~/.bashrc`, `~/.ssh/id_rsa`) have mtimes measured
in days/months, well outside the window — prompt-injection paths
pointing at pre-existing files are still rejected.
4. Hard denylist — `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`,
`/var/{log,lib,run}`, plus `$HOME/.{ssh,aws,gnupg,kube,docker,config,
azure,gcloud}` and `Library/Keychains`. Denylist blocks delivery
even when recency would trust the file, in case an attacker
somehow refreshes a sensitive file's mtime.
Operators who want strict-allowlist behavior set
`gateway.trust_recent_files: false` and the system reverts to
pre-existing behavior.
Tests: 6 new cases in test_platform_base.py cover the recency window,
disabled mode, system-path denylist, and the motivating PDF-in-project
scenario. 3 existing tests (test_platform_base, test_tts_media_routing,
test_send_message_tool) that exercised the strict-allowlist path are
updated to disable recency trust explicitly.
E2E validation: real `validate_media_delivery_path()` accepts fresh
PDFs in /tmp and project dirs, rejects /etc/passwd, ~/.ssh/id_rsa, and
files older than the window; config.yaml `gateway.*` keys bridge
correctly to the env vars the validator reads.
This commit is contained in:
parent
0ff7c09e2f
commit
a989a79c0c
6 changed files with 279 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for gateway/platforms/base.py — MessageEvent, media extraction, message truncation."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -367,6 +368,10 @@ class TestMediaDeliveryPathValidation:
|
|||
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||
tuple(roots),
|
||||
)
|
||||
# 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.
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
|
||||
|
||||
def test_allows_existing_file_inside_safe_root(self, tmp_path, monkeypatch):
|
||||
root = tmp_path / "media-cache"
|
||||
|
|
@ -426,6 +431,110 @@ class TestMediaDeliveryPathValidation:
|
|||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())
|
||||
|
||||
def test_recency_trust_allows_freshly_produced_file(self, tmp_path, monkeypatch):
|
||||
"""A PDF the agent just wrote to /tmp should be deliverable.
|
||||
|
||||
Covers the natural case: agent runs ``pandoc -o /tmp/report.pdf`` or
|
||||
``write_file('/home/user/report.pdf', ...)`` and asks the gateway to
|
||||
send the result. With recency trust on, fresh files outside the cache
|
||||
allowlist are accepted because the file's mtime is within the window.
|
||||
"""
|
||||
self._patch_roots(monkeypatch) # zero cache allowlist
|
||||
monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1")
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600")
|
||||
|
||||
fresh = tmp_path / "scratch" / "report.pdf"
|
||||
fresh.parent.mkdir(parents=True)
|
||||
fresh.write_bytes(b"%PDF-1.4")
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(fresh)) == str(fresh.resolve())
|
||||
|
||||
def test_recency_trust_rejects_old_file(self, tmp_path, monkeypatch):
|
||||
"""A pre-existing host file (~/.bashrc, /etc/passwd shape) is rejected.
|
||||
|
||||
Recency trust is the load-bearing anti-injection signal: prompt-injected
|
||||
paths point at files that have existed for days or months, well outside
|
||||
the trust window.
|
||||
"""
|
||||
self._patch_roots(monkeypatch)
|
||||
monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1")
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "60")
|
||||
|
||||
stale = tmp_path / "stale.pdf"
|
||||
stale.write_bytes(b"%PDF-1.4")
|
||||
old_mtime = time.time() - 7200 # 2 hours ago
|
||||
os.utime(stale, (old_mtime, old_mtime))
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(stale)) is None
|
||||
|
||||
def test_recency_trust_disabled_falls_back_to_pure_allowlist(self, tmp_path, monkeypatch):
|
||||
"""Setting trust_recent_files=false reverts to pre-existing strict behavior."""
|
||||
self._patch_roots(monkeypatch)
|
||||
monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
|
||||
|
||||
fresh = tmp_path / "report.pdf"
|
||||
fresh.write_bytes(b"%PDF-1.4") # mtime = now
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(fresh)) is None
|
||||
|
||||
def test_recency_trust_denies_system_paths_even_when_fresh(self, tmp_path, monkeypatch):
|
||||
"""A freshly-touched file under /etc must NOT be uploaded.
|
||||
|
||||
Belt-and-braces: even if an attacker rewrites the file's mtime
|
||||
(e.g. via a separately compromised tool result that touches a system
|
||||
file), the denylist refuses to deliver paths under /etc, /proc, /sys,
|
||||
~/.ssh, ~/.aws, etc.
|
||||
"""
|
||||
self._patch_roots(monkeypatch)
|
||||
monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1")
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600")
|
||||
|
||||
# Simulate $HOME so ~/.ssh resolves into our tmp dir.
|
||||
fake_home = tmp_path / "home"
|
||||
ssh_dir = fake_home / ".ssh"
|
||||
ssh_dir.mkdir(parents=True)
|
||||
secret = ssh_dir / "id_rsa.txt"
|
||||
secret.write_bytes(b"-----BEGIN ...") # mtime = now
|
||||
monkeypatch.setenv("HOME", str(fake_home))
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None
|
||||
|
||||
def test_recency_trust_allows_pdf_in_project_dir(self, tmp_path, monkeypatch):
|
||||
"""The motivating case: agent produces a PDF in a project directory.
|
||||
|
||||
Reproduces the Discord-PDF-not-delivered bug. Before recency trust,
|
||||
files outside ~/.hermes/cache/* were silently dropped, leaving the
|
||||
user with a raw filepath in chat instead of an attachment.
|
||||
"""
|
||||
self._patch_roots(monkeypatch)
|
||||
monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1")
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600")
|
||||
|
||||
project = tmp_path / "my-project"
|
||||
report = project / "build" / "weekly-report.pdf"
|
||||
report.parent.mkdir(parents=True)
|
||||
report.write_bytes(b"%PDF-1.4")
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(report)) == str(report.resolve())
|
||||
|
||||
def test_filter_keeps_recently_produced_files(self, tmp_path, monkeypatch):
|
||||
"""End-to-end: filter_local_delivery_paths routes a fresh PDF through."""
|
||||
self._patch_roots(monkeypatch)
|
||||
monkeypatch.delenv("HERMES_MEDIA_ALLOW_DIRS", raising=False)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "1")
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_SECONDS", "600")
|
||||
|
||||
fresh = tmp_path / "report.pdf"
|
||||
fresh.write_bytes(b"%PDF-1.4")
|
||||
|
||||
out = BasePlatformAdapter.filter_local_delivery_paths([str(fresh)])
|
||||
assert out == [str(fresh.resolve())]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_send_media_as_audio
|
||||
|
|
|
|||
|
|
@ -234,6 +234,10 @@ 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.)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
|
||||
adapter = SimpleNamespace(
|
||||
name="test",
|
||||
extract_media=BasePlatformAdapter.extract_media,
|
||||
|
|
|
|||
|
|
@ -377,7 +377,11 @@ class TestSendMessageTool:
|
|||
user_id="user-123",
|
||||
)
|
||||
|
||||
def test_media_tag_outside_allowed_roots_is_not_sent(self, tmp_path):
|
||||
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.)
|
||||
monkeypatch.setenv("HERMES_MEDIA_TRUST_RECENT_FILES", "0")
|
||||
config, telegram_cfg = _make_config()
|
||||
secret = tmp_path / "secret.pdf"
|
||||
secret.write_bytes(b"%PDF secret")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue