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>
This commit is contained in:
emozilla 2026-07-03 14:31:04 -04:00 • committed by Teknium
parent 6fcd470d54
commit 316e77517e
7 changed files with 750 additions and 98 deletions

View file

@ -0,0 +1,157 @@
"""Docker integration tests for the vision image-source resolver.
Exercises the in-sandbox exec-read path that unit tests can only mock. Two
scenarios that ONLY the container filesystem can serve:
* a tmpfs ``/workspace`` file with no host path at all, and
* a root-owned mode-600 file the host user cannot read,
both delivered by the resolver's ``base64`` exec-read fallback
(``tools/image_source._resolve_container_fallback``). This is the same path
that provides terminal-backend confinement for GHSA-gpxw-6wxv-w3qq: under a
non-local backend a non-cache path is read INSIDE the container, never on the
host.
Gating follows the repo convention: the ``integration`` marker excludes these
from the default suite (``addopts = -m 'not integration'`` in pyproject.toml);
they run under ``pytest -m integration`` when a Docker daemon is available and
skip cleanly when it is not. Container spin-up exceeds the 30s suite default,
so the timeout is bumped to 180s.
Run: pytest -m integration tests/integration/test_vision_docker_resolve.py
"""
import base64
import shutil
import subprocess
import pytest
def _docker_available() -> bool:
"""True iff a docker CLI is on PATH and the daemon answers."""
if shutil.which("docker") is None:
return False
try:
return subprocess.run(
["docker", "info"], capture_output=True, timeout=5
).returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
pytestmark = [
pytest.mark.integration,
pytest.mark.timeout(180),
pytest.mark.skipif(not _docker_available(), reason="Docker daemon not available"),
]
# A real 1x1 PNG.
_TINY_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
@pytest.fixture
def docker_backend(request, monkeypatch):
"""A live DockerEnvironment registered so ``get_active_env`` finds it.
Unique task_id per test: DockerEnvironment derives the container name from
the task_id, so a shared id would make one test's teardown remove the
other's container.
"""
from tools import terminal_tool
from tools.environments import docker as docker_env
# The resolver keys the exec-read off TERMINAL_ENV=docker.
monkeypatch.setenv("TERMINAL_ENV", "docker")
task_id = f"vision-docker-resolve-{request.node.name}"
env = docker_env.DockerEnvironment(
image="python:3.11-slim",
cwd="/workspace",
timeout=120,
task_id=task_id,
volumes=[],
network=False,
)
# Register under the raw task_id; get_active_env() falls back to the raw
# key after trying the _resolve_container_task_id() mapping.
with terminal_tool._env_lock:
terminal_tool._active_environments[task_id] = env
try:
env._task_id = task_id
yield env
finally:
with terminal_tool._env_lock:
terminal_tool._active_environments.pop(task_id, None)
try:
env.cleanup()
except Exception:
pass
def _write_png_in_container(env, path, *, mode=None):
b64 = base64.b64encode(_TINY_PNG).decode()
cmd = f"printf %s {b64} | base64 -d > {path}"
if mode:
cmd += f" && chmod {mode} {path}"
res = env.execute(cmd)
assert res.get("returncode", 1) == 0, res
@pytest.mark.asyncio
async def test_resolves_tmpfs_workspace_file(docker_backend):
"""A container-only path (no host file) is delivered via exec-read."""
from tools.image_source import ResolveContext, resolve_image_source
_write_png_in_container(docker_backend, "/workspace/shot.png")
res = await resolve_image_source(
"/workspace/shot.png", ResolveContext(task_id=docker_backend._task_id))
assert res.origin == "container"
assert res.mime == "image/png"
assert res.data == _TINY_PNG
@pytest.mark.asyncio
async def test_resolves_root_owned_mode600_file(docker_backend):
"""Root-owned mode-600 (host user can't read it) is served in-container."""
from tools.image_source import ResolveContext, resolve_image_source
_write_png_in_container(docker_backend, "/workspace/secret.png", mode="600")
res = await resolve_image_source(
"/workspace/secret.png", ResolveContext(task_id=docker_backend._task_id))
assert res.origin == "container"
assert res.mime == "image/png"
assert res.data == _TINY_PNG
@pytest.mark.asyncio
async def test_host_secret_path_reads_container_not_host(docker_backend, tmp_path):
"""The GHSA-gpxw invariant, end-to-end against real Docker.
A path that exists on the HOST with secret bytes but does NOT exist in the
container must resolve to the container read (which fails to find it) —
never to the host bytes. Proves vision cannot exfiltrate a host file under
a sandbox backend even when the exact path is real on the host.
"""
from tools.image_source import (
ImageResolutionError,
ResolveContext,
resolve_image_source,
)
# A real host file outside any media cache, holding a secret.
host_secret = tmp_path / "id_rsa"
host_secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK")
# That path does not exist inside the fresh container, so the exec-read
# finds nothing and the resolver refuses. The exact failure shape depends
# on the container's base64 build (some exit non-zero -> SourceNotFound,
# some print an error to stderr and exit 0 with empty stdout -> NotAnImage);
# both are ImageResolutionError. What matters for GHSA-gpxw: the resolver
# never returns the HOST bytes.
with pytest.raises(ImageResolutionError) as excinfo:
await resolve_image_source(
str(host_secret), ResolveContext(task_id=docker_backend._task_id))
# Belt and suspenders: the host secret must not appear anywhere in the error.
assert b"HOST-PRIVATE-KEY".decode() not in str(excinfo.value)

View file

@ -0,0 +1,191 @@
"""Tests for tools/image_source.py — the unified vision image-source resolver.
Covers the delivery contract (data:/http/file/local/container source handling,
size cap, magic-byte sniff) AND the terminal-backend confinement security model
(GHSA-gpxw-6wxv-w3qq): under a non-local backend, host reads are confined to the
media caches and every other path is read inside the sandbox via exec-read.
"""
import base64
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
JPEG = b"\xff\xd8\xff" + b"\x00" * 64
def _reload(monkeypatch, hermes_home: Path):
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
import hermes_constants
importlib.reload(hermes_constants)
import tools.image_source as isrc
importlib.reload(isrc)
return isrc
class TestDataUrl:
@pytest.mark.asyncio
async def test_valid_data_url_resolves_to_bytes(self, tmp_path, monkeypatch):
isrc = _reload(monkeypatch, tmp_path / "hermes")
b64 = base64.b64encode(PNG).decode()
res = await isrc.resolve_image_source(
f"data:image/png;base64,{b64}", isrc.ResolveContext())
assert res.data == PNG
assert res.mime == "image/png"
assert res.origin == "data"
@pytest.mark.asyncio
async def test_non_image_data_url_rejected(self, tmp_path, monkeypatch):
isrc = _reload(monkeypatch, tmp_path / "hermes")
b64 = base64.b64encode(b"not an image").decode()
with pytest.raises(isrc.NotAnImage):
await isrc.resolve_image_source(
f"data:text/plain;base64,{b64}", isrc.ResolveContext())
class TestLocalBackend:
@pytest.mark.asyncio
async def test_local_backend_reads_any_host_path(self, tmp_path, monkeypatch):
isrc = _reload(monkeypatch, tmp_path / "hermes")
monkeypatch.setenv("TERMINAL_ENV", "local")
img = tmp_path / "outside" / "pic.png"
img.parent.mkdir(parents=True)
img.write_bytes(PNG)
res = await isrc.resolve_image_source(str(img), isrc.ResolveContext())
assert res.data == PNG
assert res.origin == "file"
@pytest.mark.asyncio
async def test_file_uri_scheme_stripped(self, tmp_path, monkeypatch):
isrc = _reload(monkeypatch, tmp_path / "hermes")
monkeypatch.setenv("TERMINAL_ENV", "local")
img = tmp_path / "pic.jpg"
img.write_bytes(JPEG)
res = await isrc.resolve_image_source(f"file://{img}", isrc.ResolveContext())
assert res.mime == "image/jpeg"
class TestNonLocalBackendConfinement:
"""The security model: under a sandbox backend, host reads are confined to
the media caches; every other path is read inside the sandbox."""
@pytest.mark.asyncio
async def test_media_cache_path_host_read(self, tmp_path, monkeypatch):
home = tmp_path / "hermes"
isrc = _reload(monkeypatch, home)
monkeypatch.setenv("TERMINAL_ENV", "docker")
cached = home / "cache" / "images" / "inbound.png"
cached.parent.mkdir(parents=True)
cached.write_bytes(PNG)
# No sandbox env needed — a cache path is host-read directly.
res = await isrc.resolve_image_source(str(cached), isrc.ResolveContext())
assert res.data == PNG
assert res.origin == "file"
@pytest.mark.asyncio
async def test_host_secret_outside_cache_routes_to_sandbox_not_host(self, tmp_path, monkeypatch):
"""A non-cache host path (e.g. /etc/passwd) must NOT be host-read — it
routes to the in-sandbox exec-read, which reads the CONTAINER's file."""
home = tmp_path / "hermes"
isrc = _reload(monkeypatch, home)
monkeypatch.setenv("TERMINAL_ENV", "docker")
# A real host file outside the caches, holding a "secret".
secret = tmp_path / "id_rsa"
secret.write_bytes(b"HOST-PRIVATE-KEY-DO-NOT-LEAK")
# Fake sandbox env: its exec-read returns a *different* (container) image,
# proving we read the container filesystem, not the host secret.
container_png_b64 = base64.b64encode(PNG).decode()
calls = {}
def fake_execute(cmd, **kw):
calls["cmd"] = cmd
return {"returncode": 0, "output": container_png_b64}
with patch("tools.image_source._get_active_env",
return_value=SimpleNamespace(execute=fake_execute)):
res = await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
# Read came from the sandbox exec-read, returning the container image —
# the host secret bytes never appear.
assert res.origin == "container"
assert res.data == PNG
assert b"HOST-PRIVATE-KEY" not in res.data
assert "base64 --" in calls["cmd"] # option-injection-safe form
@pytest.mark.asyncio
async def test_non_cache_path_fails_closed_without_sandbox(self, tmp_path, monkeypatch):
"""No active sandbox env -> refuse rather than fall back to a host read."""
home = tmp_path / "hermes"
isrc = _reload(monkeypatch, home)
monkeypatch.setenv("TERMINAL_ENV", "docker")
secret = tmp_path / "id_rsa"
secret.write_bytes(b"HOST-PRIVATE-KEY")
with patch("tools.image_source._get_active_env", return_value=None):
with pytest.raises(isrc.SourceNotFound):
await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id="t1"))
@pytest.mark.asyncio
async def test_symlink_in_cache_pointing_outside_is_not_host_read(self, tmp_path, monkeypatch):
"""A symlink planted inside a cache dir that points at a host secret must
not be host-read (resolve() escapes the cache) — it routes to sandbox."""
home = tmp_path / "hermes"
isrc = _reload(monkeypatch, home)
monkeypatch.setenv("TERMINAL_ENV", "docker")
secret = tmp_path / "outside" / "id_rsa"
secret.parent.mkdir(parents=True)
secret.write_bytes(b"HOST-PRIVATE-KEY")
cache_dir = home / "cache" / "images"
cache_dir.mkdir(parents=True)
link = cache_dir / "sneaky.png"
try:
link.symlink_to(secret)
except (OSError, NotImplementedError):
pytest.skip("symlinks unsupported")
# Fails closed (no sandbox) rather than host-reading the symlink target.
with patch("tools.image_source._get_active_env", return_value=None):
with pytest.raises(isrc.SourceNotFound):
await isrc.resolve_image_source(str(link), isrc.ResolveContext(task_id="t1"))
class TestExecReadSafety:
@pytest.mark.asyncio
async def test_exec_read_uses_option_terminator(self, tmp_path, monkeypatch):
"""Leading-dash paths must not be parsed as base64 options."""
home = tmp_path / "hermes"
isrc = _reload(monkeypatch, home)
monkeypatch.setenv("TERMINAL_ENV", "docker")
captured = {}
def fake_execute(cmd, **kw):
captured["cmd"] = cmd
return {"returncode": 0, "output": base64.b64encode(PNG).decode()}
with patch("tools.image_source._get_active_env",
return_value=SimpleNamespace(execute=fake_execute)):
await isrc.resolve_image_source(
"/workspace/-i-etc-shadow.png", isrc.ResolveContext(task_id="t1"))
assert "base64 -- " in captured["cmd"]
@pytest.mark.asyncio
async def test_exec_read_nonzero_returncode_raises(self, tmp_path, monkeypatch):
home = tmp_path / "hermes"
isrc = _reload(monkeypatch, home)
monkeypatch.setenv("TERMINAL_ENV", "docker")
def fake_execute(cmd, **kw):
return {"returncode": 1, "output": ""}
with patch("tools.image_source._get_active_env",
return_value=SimpleNamespace(execute=fake_execute)):
with pytest.raises(isrc.SourceNotFound):
await isrc.resolve_image_source(
"/workspace/nope.png", isrc.ResolveContext(task_id="t1"))

View file

@ -119,7 +119,9 @@ class TestVisionAnalyzeNative:
assert isinstance(result, str)
parsed = json.loads(result)
assert parsed.get("success") is False
assert "Invalid image source" in parsed.get("error", "")
# Unified resolver: unrecognized/non-existent local source.
err = parsed.get("error", "").lower()
assert "not reachable" in err or "unrecognized image source" in err or "no active sandbox" in err
def test_empty_image_url_returns_error(self):
result = asyncio.get_event_loop().run_until_complete(

View file

@ -1,5 +1,6 @@
"""Tests for tools/vision_tools.py — URL validation, type hints, error logging."""
import base64
import json
import logging
import os
@ -381,26 +382,20 @@ class TestErrorLoggingExcInfo:
@pytest.mark.asyncio
async def test_cleanup_error_logs_exc_info(self, tmp_path, caplog):
"""Temp file cleanup failure should log warning with exc_info."""
# Create a real temp file that will be "downloaded"
temp_dir = tmp_path / "temp_vision_images"
temp_dir.mkdir()
async def fake_download(url, dest, max_retries=3):
"""Simulate download by writing file to the expected destination."""
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(b"\xff\xd8\xff" + b"\x00" * 16)
return dest
# A data: URL resolves to bytes without any network, materializes to a
# vision temp file, then the analysis runs — exercising the temp-cleanup
# path in the finally block. A tiny valid JPEG (magic bytes) passes the
# resolver's magic-byte sniff.
jpeg_b64 = base64.b64encode(b"\xff\xd8\xff" + b"\x00" * 32).decode("ascii")
data_url = f"data:image/jpeg;base64,{jpeg_b64}"
with (
patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
patch("tools.vision_tools._download_image", side_effect=fake_download),
patch(
"tools.vision_tools._image_to_base64_data_url",
return_value="data:image/jpeg;base64,abc",
),
caplog.at_level(logging.WARNING, logger="tools.vision_tools"),
):
# Mock the async_call_llm function to return a mock response
mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "A test image description"
@ -409,16 +404,12 @@ class TestErrorLoggingExcInfo:
with (
patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock, return_value=mock_response),
):
# Make unlink fail to trigger cleanup warning
original_unlink = Path.unlink
# Make unlink fail to trigger the cleanup warning.
def failing_unlink(self, *args, **kwargs):
raise PermissionError("no permission")
with patch.object(Path, "unlink", failing_unlink):
result = await vision_analyze_tool(
"https://example.com/tempimg.jpg", "describe", "test/model"
)
result = await vision_analyze_tool(data_url, "describe", "test/model")
warning_records = [
r
@ -500,7 +491,8 @@ class TestVisionSafetyGuards:
result = json.loads(await vision_analyze_tool(str(secret), "extract text"))
assert result["success"] is False
assert "Only real image files are supported" in result["error"]
# The unified resolver's magic-byte sniff rejects non-images.
assert "not a recognized image" in result["error"]
mock_llm.assert_not_awaited()
@pytest.mark.asyncio
@ -513,8 +505,8 @@ class TestVisionSafetyGuards:
}
with (
patch("tools.vision_tools.check_website_access", return_value=blocked),
patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True),
patch("tools.website_policy.check_website_access", return_value=blocked),
patch("tools.url_safety.is_safe_url", return_value=True),
patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download,
):
result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe"))
@ -1258,7 +1250,7 @@ class TestVisionCpuBurstCap:
enc_inflight -= 1
return "data:image/jpeg;base64,AAAA"
async def fake_native(image_url, question):
async def fake_native(image_url, question, task_id=None):
nonlocal calls_inflight, calls_peak
calls_inflight += 1
calls_peak = max(calls_peak, calls_inflight)

View file

@ -402,6 +402,30 @@ def map_cache_path_to_container(
return None
def from_agent_visible_cache_path(
container_path: str,
container_base: str = "/root/.hermes",
) -> str:
"""Translate a sandbox/container cache path back to its host path.
Inverse of :func:`to_agent_visible_cache_path`. Returns the input unchanged
when the active backend is not Docker, or when the path is not under any
auto-mounted cache directory — the caller then treats a still-container
path as "no host file" and falls back to an in-container read.
"""
if os.environ.get("TERMINAL_ENV", "local") != "docker":
return container_path
path = Path(container_path)
for mount in get_cache_directory_mounts(container_base=container_base):
try:
rel = path.relative_to(mount["container_path"])
except ValueError:
continue
return str(Path(mount["host_path"]) / rel)
return container_path
def to_agent_visible_cache_path(
host_path: str,
container_base: str = "/root/.hermes",

292
tools/image_source.py Normal file
View file

@ -0,0 +1,292 @@
"""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)

View file

@ -221,11 +221,14 @@ async def _validate_image_url_async(url: str) -> bool:
return await async_is_safe_url(url)
def _detect_image_mime_type(image_path: Path) -> Optional[str]:
"""Return a MIME type when the file looks like a supported image."""
with image_path.open("rb") as f:
header = f.read(64)
def _detect_image_mime_type_from_bytes(data: bytes) -> Optional[str]:
"""Magic-byte MIME sniff on raw bytes (authoritative; no extension trust).
Returns ``None`` for anything without a recognized image header — including
SVG, which has no magic bytes and is not ingestible as vision by any
provider. Callers that accept SVG-by-text (path-based) layer that on top.
"""
header = data[:64]
if header.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if header.startswith(b"\xff\xd8\xff"):
@ -236,11 +239,18 @@ def _detect_image_mime_type(image_path: Path) -> Optional[str]:
return "image/bmp"
if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
return "image/webp"
if image_path.suffix.lower() == ".svg":
return None
def _detect_image_mime_type(image_path: Path) -> Optional[str]:
"""Return a MIME type when the file looks like a supported image."""
with image_path.open("rb") as f:
mime = _detect_image_mime_type_from_bytes(f.read(64))
if mime is None and image_path.suffix.lower() == ".svg":
head = image_path.read_text(encoding="utf-8", errors="ignore")[:4096].lower()
if "<svg" in head:
return "image/svg+xml"
return None
return mime
def _is_retryable_download_error(error: Exception) -> bool:
@ -817,13 +827,14 @@ async def _vision_concurrency_slot():
async def _vision_analyze_native(
image_url: str,
question: str,
task_id: Optional[str] = None,
) -> Any:
"""Fast path for vision-capable main models.
Loads the image (local file OR remote URL), base64-encodes it, and
returns a multimodal tool-result envelope. The agent loop unwraps it;
provider adapters serialize it into the right tool-result-with-image
shape for each backend.
Loads the image (data: / http(s) / file:// / local path / sandbox-container
path) via the unified resolver, base64-encodes it, and returns a multimodal
tool-result envelope. The agent loop unwraps it; provider adapters serialize
it into the right tool-result-with-image shape for each backend.
Returns:
A ``_multimodal`` envelope dict on success.
@ -840,38 +851,28 @@ async def _vision_analyze_native(
if is_interrupted():
return tool_error("Interrupted", success=False)
# Resolve the image source (mirrors vision_analyze_tool's logic
# exactly so behaviour is consistent).
resolved_url = image_url
if resolved_url.startswith("file://"):
resolved_url = resolved_url[len("file://"):]
local_path = Path(os.path.expanduser(resolved_url))
# Resolve the source to raw bytes through the single resolver (unifies
# data:/http/file/local/container and enforces terminal-backend
# confinement). Materialize to a temp file so the existing path-based
# encode/resize/embed-cap pipeline below is reused verbatim.
from tools.image_source import (
ImageResolutionError,
ResolveContext,
resolve_image_source,
)
if local_path.is_file():
temp_image_path = local_path
should_cleanup = False
elif await _validate_image_url_async(image_url):
blocked = check_website_access(image_url)
if blocked:
return tool_error(blocked["message"], success=False)
temp_dir = get_hermes_dir("cache/vision", "temp_vision_images")
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg"
await _download_image(image_url, temp_image_path)
should_cleanup = True
else:
return tool_error(
"Invalid image source. Provide an HTTP/HTTPS URL or a "
"valid local file path.",
success=False,
)
try:
resolved = await resolve_image_source(image_url, ResolveContext(task_id=task_id))
except ImageResolutionError as exc:
return tool_error(str(exc), success=False)
image_size_bytes = temp_image_path.stat().st_size
detected_mime_type = _detect_image_mime_type(temp_image_path)
if not detected_mime_type:
return tool_error(
"Only real image files are supported for vision analysis.",
success=False,
)
detected_mime_type = resolved.mime
image_size_bytes = len(resolved.data)
temp_dir = get_hermes_dir("cache/vision", "temp_vision_images")
temp_dir.mkdir(parents=True, exist_ok=True)
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.img"
temp_image_path.write_bytes(resolved.data)
should_cleanup = True
image_data_url = await _run_encode_on_cpu_executor(
_image_to_base64_data_url,
@ -935,6 +936,7 @@ async def vision_analyze_tool(
image_url: str,
user_prompt: str,
model: str = None,
task_id: Optional[str] = None,
) -> str:
"""
Analyze an image from a URL or local file path using vision AI.
@ -996,42 +998,33 @@ async def vision_analyze_tool(
logger.info("Analyzing image: %s", image_url[:60])
logger.info("User prompt: %s", user_prompt[:100])
# Determine if this is a local file path or a remote URL
# Strip file:// scheme so file URIs resolve as local paths.
resolved_url = image_url
if resolved_url.startswith("file://"):
resolved_url = resolved_url[len("file://"):]
local_path = Path(os.path.expanduser(resolved_url))
if local_path.is_file():
# Local file path (e.g. from platform image cache) -- skip download
logger.info("Using local image file: %s", image_url)
temp_image_path = local_path
should_cleanup = False # Don't delete cached/local files
elif await _validate_image_url_async(image_url):
# Remote URL -- download to a temporary location
blocked = check_website_access(image_url)
if blocked:
raise PermissionError(blocked["message"])
logger.info("Downloading image from URL...")
temp_dir = get_hermes_dir("cache/vision", "temp_vision_images")
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.jpg"
await _download_image(image_url, temp_image_path)
should_cleanup = True
else:
raise ValueError(
"Invalid image source. Provide an HTTP/HTTPS URL or a valid local file path."
)
# Resolve the source to raw bytes through the single resolver (unifies
# data:/http/file/local/container and enforces terminal-backend
# confinement). Materialize to a temp file so the existing path-based
# encode/resize pipeline below is reused verbatim.
from tools.image_source import (
ImageResolutionError,
ResolveContext,
resolve_image_source,
)
try:
resolved = await resolve_image_source(image_url, ResolveContext(task_id=task_id))
except ImageResolutionError as exc:
raise ValueError(str(exc))
detected_mime_type = resolved.mime
temp_dir = get_hermes_dir("cache/vision", "temp_vision_images")
temp_dir.mkdir(parents=True, exist_ok=True)
temp_image_path = temp_dir / f"temp_image_{uuid.uuid4()}.img"
temp_image_path.write_bytes(resolved.data)
should_cleanup = True
# Get image file size for logging
image_size_bytes = temp_image_path.stat().st_size
image_size_bytes = len(resolved.data)
image_size_kb = image_size_bytes / 1024
logger.info("Image ready (%.1f KB)", image_size_kb)
detected_mime_type = _detect_image_mime_type(temp_image_path)
if not detected_mime_type:
raise ValueError("Only real image files are supported for vision analysis.")
# Convert image to base64 — send at full resolution first.
# If the provider rejects it as too large, we auto-resize and retry.
# Offloaded to the bounded vision CPU executor so a fan-out of encodes
@ -1335,6 +1328,7 @@ VISION_ANALYZE_SCHEMA = {
async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:
image_url = args.get("image_url", "")
question = args.get("question", "")
task_id = kw.get("task_id")
# The fan-out cap lives inside the encode/resize step (offloaded to the
# bounded _vision_cpu_executor), NOT around the whole analysis — so a
@ -1349,7 +1343,7 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:
# information loss, no extra latency.
if _should_use_native_vision_fast_path():
logger.info("vision_analyze: native fast path")
return await _vision_analyze_native(image_url, question)
return await _vision_analyze_native(image_url, question, task_id=task_id)
# Legacy path: aux LLM describes the image and we return its text.
full_prompt = (
@ -1368,7 +1362,7 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:
pass
if not model:
model = os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
return await vision_analyze_tool(image_url, full_prompt, model)
return await vision_analyze_tool(image_url, full_prompt, model, task_id=task_id)
registry.register(