hermes-agent/tools/image_source.py
emozilla 316e77517e fix(vision): unified image-source resolver + terminal-backend confinement
Salvage of #35362, evolved to also close the vision sandbox-escape
(GHSA-gpxw-6wxv-w3qq). The two were the same root cause — vision read image
bytes host-side while every other tool reads through the terminal backend —
so one resolver fixes both the delivery gaps and the escape.

Delivery (from #35362, re-authored against current main since the branch was
4140 commits stale and vision_tools.py had been rewritten on both sides):
- tools/image_source.py: one resolver for data:/http(s)/file/local/container
  image sources, returning raw bytes through a single magic-byte-sniff +
  50MB-ingest chokepoint. Fixes 'no image attached' / 'Invalid image source'
  for every source type (#7571, #25118, #29643, #22328, #32709, #9077).
- tools/credential_files.py: from_agent_visible_cache_path, the container->host
  cache reverse-map (inverse of the existing forward twin).
- tools/vision_tools.py: both vision sites route through the resolver with
  task_id threaded from the handler; resolved bytes are materialized to a temp
  file so main's evolved encode/resize/embed-cap pipeline is reused verbatim
  (kept over the PR's older bytes-core resize to avoid touching browser_tool /
  conversation_compression callers).

Security (fills #35362's deliberately-stubbed _within_allowed_roots seam):
- Under a non-local terminal backend the file tools are confined to the sandbox
  (SECURITY.md 2.2), but vision read host-side — a prompt-injected
  vision_analyze('/etc/passwd') exfiltrated host secrets, and read_file even
  redirects the model to vision_analyze for image paths. The resolver now
  enforces the same boundary: local backend reads any host path (chosen
  posture); non-local backend host-reads ONLY the media caches under
  HERMES_HOME (where the gateway/download media lives) and routes every other
  path to an in-sandbox base64 exec-read — which reads the CONTAINER's file,
  the same one 'cat' would, never the host's. Paths are resolve()-d so a
  symlink can't escape a cache; fail-closed when no sandbox env exists.
  This closes the escape AND delivers container-only images (#32709) with the
  same mechanism.

Tests: unified resolver + confinement model (tests/tools/test_image_source.py,
incl. proof a non-cache host path under Docker yields container bytes not the
host secret); existing vision tests updated to the resolver boundary; Docker
integration test verified green against a real daemon (exec-read of a tmpfs
/workspace file, a root-owned mode-600 file, and the host-secret invariant).

Fixes GHSA-gpxw-6wxv-w3qq.
Co-authored-by: banditburai <promptsiren@gmail.com>
2026-07-04 15:30:50 -07:00

292 lines
12 KiB
Python

"""Single resolver for every vision_analyze image source -> bytes + mime.
All source handling (data:/http(s)/file/local/container) funnels through
:func:`resolve_image_source` so size and magic-byte checks are enforced exactly
once. Returns raw bytes (not a path): the downstream step is base64 -> data URL
(RFC 2397) and provider base64 content blocks.
Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local
terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2),
but vision read images host-side. This resolver enforces the same boundary:
* local backend -> read any host path (chosen posture, unchanged)
* non-local backend:
path in a media cache -> host-read (the gateway/download caches live on
the host and are bind-mounted into the sandbox)
path anywhere else -> read the bytes *inside the sandbox* via exec-read
(the agent can already ``cat`` any container file;
this stays within the sandbox boundary and never
reaches the host's ``/etc/passwd`` / ``~/.ssh``).
So a prompt-injected ``vision_analyze('/etc/passwd')`` under Docker reads the
*container's* file (what every other tool sees), not the host's — no escape —
while container-only images (tmpfs ``/workspace``, root-owned) are still
deliverable. This is the unified delivery + confinement model: the same
mechanism that fixes "vision can't see container files" also closes the escape.
"""
from __future__ import annotations
import base64
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
# Raw-bytes INGEST budget — what the resolver will load before handing off.
# This is deliberately the 50MB download cap (tools/vision_tools._VISION_MAX_DOWNLOAD_BYTES),
# NOT the 20MB provider payload cap. The 20MB cap (_MAX_BASE64_BYTES) is a
# *post-resize* limit enforced at the call sites: an oversized raw image must
# still reach the resizer so it can be downscaled under the payload cap. Capping
# raw bytes at 20MB here would reject every 20-50MB photo before resize can run.
_MAX_INGEST_BYTES = 50 * 1024 * 1024
class ImageResolutionError(Exception):
def __init__(self, message: str, *, src: str = "", origin: str = ""):
super().__init__(message)
self.src, self.origin = src, origin
class UnsupportedScheme(ImageResolutionError):
pass
class SourceUnsafe(ImageResolutionError): # SSRF / path-allowlist
pass
class SourceTooLarge(ImageResolutionError):
pass
class SourceNotFound(ImageResolutionError):
pass
class NotAnImage(ImageResolutionError):
pass
@dataclass
class ResolveContext:
task_id: Optional[str] = None
cfg: Optional[dict[str, Any]] = None
extra_roots: tuple = field(default_factory=tuple)
@dataclass
class ResolvedImage:
data: bytes
mime: str
origin: str # one of: data | http | file | local | container
async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage:
if not isinstance(src, str) or not src.strip():
raise SourceNotFound("image_url is required", src=str(src))
s = src.strip()
if s.startswith("data:"):
data, mime = _resolve_data_url(s)
return _finalize(data, mime, "data", s)
if s.startswith(("http://", "https://")):
reason = _http_block_reason(s)
if reason:
raise SourceUnsafe(reason, src=s)
return _finalize(await _download_to_bytes(s), "", "http", s)
candidate = s[len("file://"):] if s.startswith("file://") else s
if s.startswith("file://") or _looks_like_path(candidate):
p = Path(os.path.expanduser(candidate))
# Confinement decision (see module docstring). Under a non-local backend
# a path is host-readable ONLY if it lands in a media cache (after
# translating a container-visible cache path back to its host mount);
# every other path is read inside the sandbox via exec-read, so a host
# path outside the caches never yields the host's bytes.
host_target = _permitted_host_read_target(p, ctx)
if host_target is not None and host_target.is_file():
return _finalize(host_target.read_bytes(), "", "file", s)
# Not a permitted host read (or the host file is absent) -> read the
# bytes inside the sandbox. On the local backend this reaches the same
# host file; under a sandbox it reads the container's filesystem.
return await _resolve_container_fallback(p, ctx, s)
raise UnsupportedScheme(
"Unrecognized image source. Use an http(s) URL, a local file path, "
"a file:// URI, or a data: URL.",
src=s,
)
def _resolve_data_url(s: str) -> tuple[bytes, str]:
header, _, payload = s.partition(",")
if ";base64" not in header:
raise NotAnImage("data: URL must be base64-encoded", src=s[:64])
declared = header[len("data:"):].split(";", 1)[0].strip() or "application/octet-stream"
# Cheap pre-decode size gate on the encoded length (~4/3 expansion).
if (len(payload) * 3) // 4 > _MAX_INGEST_BYTES:
raise SourceTooLarge("data: URL exceeds size limit", src=s[:64])
try:
data = base64.b64decode(payload, validate=True)
except Exception as exc:
raise NotAnImage(f"invalid base64 in data: URL: {exc}", src=s[:64])
return data, declared # real mime verified in _finalize via magic bytes
def _http_block_reason(url: str) -> Optional[str]:
"""Return a human-readable block reason, or None when the URL is allowed.
Preserves the specific website-policy message (rather than collapsing it to
a generic string) so the agent sees *why* a fetch was refused.
"""
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access
if not is_safe_url(url):
return "blocked: unsafe or private URL"
blocked = check_website_access(url)
if blocked:
return blocked.get("message") or "blocked by website policy"
return None
async def _download_to_bytes(url: str) -> bytes:
import tempfile
from tools.vision_tools import _download_image
with tempfile.NamedTemporaryFile(suffix=".img", delete=False) as tf:
tmp = Path(tf.name)
try:
await _download_image(url, tmp) # enforces the 50MB stream cap + redirect SSRF guard
return tmp.read_bytes()
finally:
tmp.unlink(missing_ok=True)
def _looks_like_path(s: str) -> bool:
return s.startswith(("/", "~", "./", "../")) or (len(s) > 1 and s[1] == ":")
def _is_local_terminal_backend() -> bool:
"""True when the terminal backend runs directly on the host.
Mirrors ``tools.browser_tool._is_local_backend`` and terminal_tool's own
dispatch, which key off ``TERMINAL_ENV``.
"""
return os.getenv("TERMINAL_ENV", "local").strip().lower() in ("local", "")
def _media_cache_roots() -> list:
"""Agent-managed media cache directories under HERMES_HOME (host side).
The only host paths vision may read under a non-local backend: gateway-
downloaded inbound media and the tools' own URL-download temp dirs. Covers
the consolidated ``cache/`` layout and the legacy flat directories.
"""
from hermes_constants import get_hermes_home
home = get_hermes_home()
return [
home / "cache", # cache/images, cache/vision, cache/video(s), cache/audio
home / "image_cache",
home / "audio_cache",
home / "video_cache",
home / "temp_vision_images",
home / "temp_video_files",
]
def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]:
"""Return the host path to read, or ``None`` if a host read is not permitted.
- Local backend: any path is permitted (chosen posture). Returns ``p``.
- Non-local backend: permitted only if the path resolves inside a media
cache root. A container-visible cache path (e.g. ``/root/.hermes/cache/
images/x.png``) is first translated back to its host mount; anything that
is not under a cache returns ``None`` so the caller routes it to the
in-sandbox exec-read instead of reading the host filesystem.
"""
if _is_local_terminal_backend():
try:
return p.resolve()
except Exception: # noqa: BLE001 — unresolved path: let is_file() fail downstream
return p
from tools.credential_files import from_agent_visible_cache_path
host_candidate = Path(from_agent_visible_cache_path(str(p)))
try:
real = host_candidate.resolve()
except Exception: # noqa: BLE001 — cannot resolve -> not a safe host read
return None
for root in _media_cache_roots():
try:
real.relative_to(root.resolve())
return real
except ValueError:
continue
return None
def _get_active_env(task_id: Optional[str]):
if not task_id:
return None
try:
from tools.terminal_tool import get_active_env
return get_active_env(task_id)
except Exception:
return None
async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) -> ResolvedImage:
"""Read the image bytes inside the sandbox (fail-closed when none exists).
Reached when a host read is not permitted or the host file is absent. The
agent can already ``cat`` any container file (file_operations.py reads
root-owned mode-600 files this way), so this stays within the same sandbox
boundary and never touches the host filesystem. ``--`` stops a leading-dash
path from being parsed as a ``base64`` option; ``base64 -w0`` is GNU-only,
so pipe through ``tr -d`` for BusyBox.
Fail-closed: if there is no active sandbox env we refuse rather than falling
back to a host read, so a non-cache host path under a sandbox never leaks.
"""
import asyncio
import shlex
env = _get_active_env(ctx.task_id)
if env is None:
raise SourceNotFound(
f"'{p}' is not reachable inside the sandbox and no active sandbox "
f"session is available to read it",
src=src, origin="container")
# env.execute is a blocking backend exec; keep it off the event loop so a
# multi-MB base64 read doesn't stall every other coroutine.
res = await asyncio.to_thread(
env.execute, f"base64 -- {shlex.quote(str(p))} | tr -d '\\n'")
if res.get("returncode", 1) != 0:
raise SourceNotFound(f"could not read '{p}' inside the sandbox", src=src, origin="container")
try:
data = base64.b64decode(res.get("output", ""), validate=True)
except Exception as exc:
raise NotAnImage(f"sandbox returned non-image data for '{p}': {exc}", src=src)
return _finalize(data, "", "container", src)
def _finalize(data: bytes, declared_mime: str, origin: str, src: str) -> ResolvedImage:
"""Intrinsic-correctness chokepoint: ingest byte cap + magic-byte sniff.
The cap here is the generous 50MB *ingest* budget, not the 20MB provider
payload cap — a 20-50MB image must survive this step so the call site can
resize it under the payload cap. See ``_MAX_INGEST_BYTES``.
"""
from tools.vision_tools import _detect_image_mime_type_from_bytes
if len(data) > _MAX_INGEST_BYTES:
raise SourceTooLarge("image exceeds size limit", src=src, origin=origin)
sniffed = _detect_image_mime_type_from_bytes(data)
if sniffed is None:
raise NotAnImage("source is not a recognized image", src=src, origin=origin)
return ResolvedImage(data=data, mime=sniffed, origin=origin)