hermes-agent/tests/tools/test_image_source.py

192 lines
7.9 KiB
Python

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