mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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>
157 lines
5.8 KiB
Python
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)
|