"""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" @pytest.mark.asyncio async def test_bare_relative_path_resolves(self, tmp_path, monkeypatch): """A cwd-relative bare filename ('pic.png') is a valid local source — main accepted it; the resolver must not regress it (PR review).""" isrc = _reload(monkeypatch, tmp_path / "hermes") monkeypatch.setenv("TERMINAL_ENV", "local") img = tmp_path / "pic.png" img.write_bytes(PNG) monkeypatch.chdir(tmp_path) res = await isrc.resolve_image_source("pic.png", isrc.ResolveContext()) assert res.data == PNG assert res.origin == "file" @pytest.mark.asyncio async def test_unknown_url_scheme_rejected(self, tmp_path, monkeypatch): isrc = _reload(monkeypatch, tmp_path / "hermes") monkeypatch.setenv("TERMINAL_ENV", "local") with pytest.raises(isrc.UnsupportedScheme): await isrc.resolve_image_source( "ftp://example.com/pic.png", isrc.ResolveContext()) @pytest.mark.asyncio async def test_svg_passes_through_for_rasterization(self, tmp_path, monkeypatch): """SVG has no raster magic bytes but is passed through with mime image/svg+xml so the vision call sites can rasterize it to PNG.""" isrc = _reload(monkeypatch, tmp_path / "hermes") monkeypatch.setenv("TERMINAL_ENV", "local") svg = tmp_path / "art.svg" svg_bytes = b'' svg.write_bytes(svg_bytes) res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext()) assert res.mime == "image/svg+xml" assert res.data == svg_bytes 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 "head -c" in calls["cmd"] and "< " in calls["cmd"] # bounded, redirect-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_is_bounded_and_redirect_safe(self, tmp_path, monkeypatch): """Leading-dash paths go through an input redirect (no argv exposure) and the read is size-bounded via head -c.""" 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 f"head -c {isrc._MAX_INGEST_BYTES + 1} < " in captured["cmd"] assert "'-i-etc-shadow.png'" in captured["cmd"] or "-i-etc-shadow.png" in captured["cmd"] @pytest.mark.asyncio async def test_exec_read_over_cap_rejected(self, tmp_path, monkeypatch): """A sandbox file larger than the ingest cap is rejected, not embedded.""" home = tmp_path / "hermes" isrc = _reload(monkeypatch, home) monkeypatch.setenv("TERMINAL_ENV", "docker") # head -c returns cap+1 bytes for an oversized file. over = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * (isrc._MAX_INGEST_BYTES - 7)).decode() def fake_execute(cmd, **kw): return {"returncode": 0, "output": over} with patch("tools.image_source._get_active_env", return_value=SimpleNamespace(execute=fake_execute)): with pytest.raises(isrc.SourceTooLarge): await isrc.resolve_image_source( "/workspace/huge.png", isrc.ResolveContext(task_id="t1")) @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")) class TestSvgNormalization: """SVG resolves end-to-end: the resolver passes it through as image/svg+xml and the vision call sites rasterize it to PNG via _normalize_to_supported_image (PR #52688, folded in).""" @pytest.mark.asyncio async def test_svg_rasterized_when_converter_available(self, tmp_path, monkeypatch): from tools import vision_tools as vt isrc = _reload(monkeypatch, tmp_path / "hermes") monkeypatch.setenv("TERMINAL_ENV", "local") svg = tmp_path / "art.svg" svg.write_bytes(b'') def fake_rasterize(svg_path, out_path): out_path.write_bytes(PNG) return True with patch.object(vt, "_rasterize_svg_to_png", side_effect=fake_rasterize): res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext()) assert res.mime == "image/svg+xml" path, mime, err = vt._normalize_to_supported_image(svg, "image/svg+xml") assert err is None assert mime == "image/png" assert path.read_bytes() == PNG path.unlink() def test_svg_actionable_error_when_no_converter(self, tmp_path, monkeypatch): from tools import vision_tools as vt _reload(monkeypatch, tmp_path / "hermes") svg = tmp_path / "art.svg" svg.write_bytes(b'') with patch.object(vt, "_rasterize_svg_to_png", return_value=False): path, mime, err = vt._normalize_to_supported_image(svg, "image/svg+xml") assert path is None assert "rasterizer" in err