hermes-agent/tests/integration/test_vision_docker_resolve.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

157 lines
5.8 KiB
Python

"""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)