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:
Teknium 2026-05-25 05:34:31 -07:00 committed by GitHub
parent 0ff7c09e2f
commit a989a79c0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 279 additions and 1 deletions

View file

@ -827,6 +827,8 @@ DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
_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"
MEDIA_DELIVERY_SAFE_ROOTS = (
IMAGE_CACHE_DIR,
AUDIO_CACHE_DIR,
@ -840,6 +842,48 @@ MEDIA_DELIVERY_SAFE_ROOTS = (
_HERMES_HOME / "browser_screenshots",
)
# Default recency window for trusting freshly-produced files (seconds).
# The agent's actual work generally completes well inside 10 minutes; legitimate
# build artifacts (PDFs from pandoc, plots from matplotlib, etc.) almost always
# land seconds before delivery. Old system files (/etc/passwd, ~/.ssh/id_rsa,
# stray credentials) have mtimes measured in days or months — well outside this
# window — so prompt-injection paths pointing at pre-existing host files are
# still rejected.
_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS = 600
# Hard denylist applied even when a path would otherwise pass recency trust.
# These prefixes hold credentials, system state, or process introspection that
# should never be uploaded as a gateway attachment, regardless of how new the
# file looks. The cache-dir allowlist still beats this — an operator-configured
# allowed root can intentionally live under one of these prefixes (rare, but
# their choice).
_MEDIA_DELIVERY_DENIED_PREFIXES = (
"/etc",
"/proc",
"/sys",
"/dev",
"/root",
"/boot",
"/var/log",
"/var/lib",
"/var/run",
)
# Within $HOME we additionally deny common credential / config directories.
# Resolved at check time against the live $HOME so containers and alt-home
# setups work correctly.
_MEDIA_DELIVERY_DENIED_HOME_SUBPATHS = (
".ssh",
".aws",
".gnupg",
".kube",
".docker",
".config",
".azure",
".gcloud",
"Library/Keychains", # macOS
)
def _media_delivery_allowed_roots() -> List[Path]:
"""Return roots from which model-emitted local media may be delivered."""
@ -856,6 +900,67 @@ def _media_delivery_allowed_roots() -> List[Path]:
return roots
def _media_delivery_recency_seconds() -> float:
"""Return the recency window for trusting freshly-produced files.
0 disables recency-based trust entirely (pure-allowlist mode).
"""
raw = os.environ.get(MEDIA_DELIVERY_TRUST_RECENT_ENV, "1").strip().lower()
if raw in ("0", "false", "no", "off", ""):
return 0.0
try:
custom = os.environ.get(MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV, "").strip()
if custom:
seconds = float(custom)
return max(0.0, seconds)
except (TypeError, ValueError):
pass
return float(_MEDIA_DELIVERY_TRUST_RECENT_DEFAULT_SECONDS)
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]
home = Path(os.path.expanduser("~"))
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
denied.append(home / sub)
# The Hermes home itself contains credentials (auth.json, .env) — only the
# cache subdirectories under it are explicitly allowlisted above.
denied.append(_HERMES_HOME / ".env")
denied.append(_HERMES_HOME / "auth.json")
denied.append(_HERMES_HOME / "credentials")
return denied
def _path_under_denied_prefix(resolved: Path) -> bool:
"""Return True if ``resolved`` lives under a deny-listed system path."""
for denied in _media_delivery_denied_paths():
try:
resolved_denied = denied.expanduser().resolve(strict=False)
except (OSError, RuntimeError, ValueError):
continue
if _path_is_within(resolved, resolved_denied) or resolved == resolved_denied:
return True
return False
def _file_is_recently_produced(resolved: Path, window_seconds: float) -> bool:
"""Return True if the file's mtime is within ``window_seconds`` of now.
Used as a session-scoped trust signal: agents almost always produce
delivery artifacts within seconds of asking to send them, while
prompt-injection paths pointing at pre-existing host files (/etc/passwd,
~/.ssh/id_rsa) have mtimes measured in days or months.
"""
if window_seconds <= 0:
return False
try:
mtime = resolved.stat().st_mtime
except OSError:
return False
return (time.time() - mtime) <= window_seconds
def _path_is_within(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
@ -902,6 +1007,16 @@ 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
# credential locations remain blocked even when "recent" — see
# ``_MEDIA_DELIVERY_DENIED_PREFIXES`` for the denylist.
window = _media_delivery_recency_seconds()
if window > 0 and not _path_under_denied_prefix(resolved):
if _file_is_recently_produced(resolved, window):
return str(resolved)
return None

View file

@ -932,6 +932,27 @@ 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_cfg = _cfg.get("gateway", {})
if isinstance(_gateway_cfg, dict):
_allow_dirs = _gateway_cfg.get("media_delivery_allow_dirs")
if _allow_dirs:
if isinstance(_allow_dirs, str):
_allow_dirs_str = _allow_dirs
elif isinstance(_allow_dirs, (list, tuple)):
_allow_dirs_str = os.pathsep.join(str(p) for p in _allow_dirs if p)
else:
_allow_dirs_str = ""
if _allow_dirs_str:
os.environ["HERMES_MEDIA_ALLOW_DIRS"] = _allow_dirs_str
_trust_recent = _gateway_cfg.get("trust_recent_files")
if _trust_recent is not None:
os.environ["HERMES_MEDIA_TRUST_RECENT_FILES"] = (
"1" if _trust_recent else "0"
)
_trust_recent_seconds = _gateway_cfg.get("trust_recent_files_seconds")
if _trust_recent_seconds is not None:
os.environ["HERMES_MEDIA_TRUST_RECENT_SECONDS"] = str(_trust_recent_seconds)
except Exception as _bridge_err:
# Previously this was silent (`except Exception: pass`), which
# hid partial bridge failures and let .env defaults shadow