{"repo":"NousResearch/hermes-agent","public_id":"32709","display_label":"Vision image-source resolution and Docker backend patch","summary":"These items address a bug where the `vision_analyze` tool does not send images when using the Docker terminal backend, and introduce a unified image-source resolver to consolidate how image sources are handled across terminal backends.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57890,"merged_at":"2026-07-04T22:30:50+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-04T22:30:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":7571,"pr_number":57890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-04T22:30:50+00:00"},{"issue_number":22328,"pr_number":57890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-04T22:30:50+00:00"},{"issue_number":25118,"pr_number":57890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-04T22:30:50+00:00"},{"issue_number":29643,"pr_number":57890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-04T22:30:50+00:00"},{"issue_number":32709,"pr_number":57890,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-04T22:30:50+00:00"}],"members":[{"kind":"issue","number":32709,"title":"Bug: `vision_analyze` tool fails to send images when using Docker terminal backend","url":null,"state":"closed"},{"kind":"pr","number":35362,"title":"fix(vision): unify vision_analyze image-source resolution through one resolver","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57890","number":57890}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57890,"title":"fix(vision): unified image-source resolver + terminal-backend confinement","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/release.py","tests/integration/test_vision_docker_resolve.py","tests/tools/test_image_source.py","tests/tools/test_vision_native_fast_path.py","tests/tools/test_vision_tools.py","tools/credential_files.py","tools/image_source.py","tools/vision_tools.py"],"fix_hunks":[{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"JAlmanzarMint\",  # PR #52688 salvage (vision: rasterize SVG / re-encode unsupported raster formats to PNG before embedding), folded into #57890\n     \"[redacted:email]\": \"ooiuuii\",  # PR #58466/#58377 salvage (redact: fireworks fw-/fpk_ prefixes; telegram: redact bot tokens out of transport error strings)\n     \"[redacted:email]\": \"msh01\",  # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).\n     \"[redacted:email]\": \"danilofalcao\",  # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)"]},{"file":"tests/integration/test_vision_docker_resolve.py","hunks":["@@ -0,0 +1,157 @@\n+\"\"\"Docker integration tests for the vision image-source resolver.\n+\n+Exercises the in-sandbox exec-read path that unit tests can only mock. Two\n+scenarios that ONLY the container filesystem can serve:\n+\n+  * a tmpfs ``/workspace`` file with no host path at all, and\n+  * a root-owned mode-600 file the host user cannot read,\n+\n+both delivered by the resolver's ``base64`` exec-read fallback\n+(``tools/image_source._resolve_container_fallback``). This is the same path\n+that provides terminal-backend confinement for GHSA-gpxw-6wxv-w3qq: under a\n+non-local backend a non-cache path is read INSIDE the container, never on the\n+host.\n+\n+Gating follows the repo convention: the ``integration`` marker excludes these\n+from the default suite (``addopts = -m 'not integration'`` in pyproject.toml);\n+they run under ``pytest -m integration`` when a Docker daemon is available and\n+skip cleanly when it is not. Container spin-up exceeds the 30s suite default,\n+so the timeout is bumped to 180s.\n+\n+Run:  pytest -m integration tests/integration/test_vision_docker_resolve.py\n+\"\"\"\n+import base64\n+import shutil\n+import subprocess\n+\n+import pytest\n+\n+\n+def _docker_available() -> bool:\n+    \"\"\"True iff a docker CLI is on PATH and the daemon answers.\"\"\"\n+    if shutil.which(\"docker\") is None:\n+        return False\n+    try:\n+        return subprocess.run(\n+            [\"docker\", \"info\"], capture_output=True, timeout=5\n+        ).returncode == 0\n+    except (subprocess.TimeoutExpired, OSError):\n+        return False\n+\n+\n+pytestmark = [\n+    pytest.mark.integration,\n+    pytest.mark.timeout(180),\n+    pytest.mark.skipif(not _docker_available(), reason=\"Docker daemon not available\"),\n+]\n+\n+# A real 1x1 PNG.\n+_TINY_PNG = base64.b64decode(\n+    \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n+)\n+\n+\n[redacted:email]\n+def docker_backend(request, monkeypatch):\n+    \"\"\"A live DockerEnvironment registered so ``get_active_env`` finds it.\n+\n+    Unique task_id per test: DockerEnvironment derives the container name from\n+    the task_id, so a shared id would make one test's teardown remove the\n+    other's container.\n+    \"\"\"\n+    from tools import terminal_tool\n+    from tools.environments import docker as docker_env\n+\n+    # The resolver keys the exec-read off TERMINAL_ENV=docker.\n+    monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+\n+    task_id = f\"vision-docker-resolve-{request.node.name}\"\n+    env = docker_env.DockerEnvironment(\n+        image=\"python:3.11-slim\",\n+        cwd=\"/workspace\",\n+        timeout=120,\n+        task_id=task_id,\n+        volumes=[],\n+        network=False,\n+    )\n+    # Register under the raw task_id; get_active_env() falls back to the raw\n+    # key after trying the _resolve_container_task_id() mapping.\n+    with terminal_tool._env_lock:\n+        terminal_tool._active_environments[task_id] = env\n+    try:\n+        env._task_id = task_id\n+        yield env\n+    finally:\n+        with terminal_tool._env_lock:\n+            terminal_tool._active_environments.pop(task_id, None)\n+        try:\n+            env.cleanup()\n+        except Exception:\n+            pass\n+\n+\n+def _write_png_in_container(env, path, *, mode=None):\n+    b64 = base64.b64encode(_TINY_PNG).decode()\n+    cmd = f\"printf %s {b64} | base64 -d > {path}\"\n+    if mode:\n+        cmd += f\" && chmod {mode} {path}\"\n+    res = env.execute(cmd)\n+    assert res.get(\"returncode\", 1) == 0, res\n+\n+\n[redacted:email]\n+async def test_resolves_tmpfs_workspace_file(docker_backend):\n+    \"\"\"A container-only path (no host file) is delivered via exec-read.\"\"\"\n+    from tools.image_source import ResolveContext, resolve_image_source\n+\n+    _write_png_in_container(docker_backend, \"/workspace/shot.png\")\n+    res = await resolve_image_source(\n+        \"/workspace/shot.png\", ResolveContext(task_id=docker_backend._task_id))\n+    assert res.origin == \"container\"\n+    assert res.mime == \"image/png\"\n+    assert res.data == _TINY_PNG\n+\n+\n[redacted:email]\n+async def test_resolves_root_owned_mode600_file(docker_backend):\n+    \"\"\"Root-owned mode-600 (host user can't read it) is served in-container.\"\"\"\n+    from tools.image_source import ResolveContext, resolve_image_source\n+\n+    _write_png_in_container(docker_backend, \"/workspace/secret.png\", mode=\"600\")\n+    res = await resolve_image_source(\n+        \"/workspace/secret.png\", ResolveContext(task_id=docker_backend._task_id))\n+    assert res.origin == \"container\"\n+    assert res.mime == \"image/png\"\n+    assert res.data == _TINY_PNG\n+\n+\n[redacted:email]\n+async def test_host_secret_path_reads_container_not_host(docker_backend, tmp_path):\n+    \"\"\"The GHSA-gpxw invariant, end-to-end against real Docker.\n+\n+    A path that exists on the HOST with secret bytes but does NOT exist in the\n+    container must resolve to the container read (which fails to find it) —\n+    never to the host bytes. Proves vision cannot exfiltrate a host file under\n+    a sandbox backend even when the exact path is real on the host.\n+    \"\"\"\n+    from tools.image_source import (\n+        ImageResolutionError,\n+        ResolveContext,\n+        resolve_image_source,\n+    )\n+\n+    # A real host file outside any media cache, holding a secret.\n+    host_secret = [redacted:secret-assignment] / \"id_rsa\"\n+    host_secret.write_bytes(b\"HOST-PRIVATE-KEY-DO-NOT-LEAK\")\n+\n+    # That path does not exist inside the fresh container, so the exec-read\n+    # finds nothing and the resolver refuses. The exact failure shape depends\n+    # on the container's base64 build (some exit non-zero -> SourceNotFound,\n+    # some print an error to stderr and exit 0 with empty stdout -> NotAnImage);\n+    # both are ImageResolutionError. What matters for GHSA-gpxw: the resolver\n+    # never returns the HOST bytes.\n+    with pytest.raises(ImageResolutionError) as excinfo:\n+        await resolve_image_source(\n+            str(host_secret), ResolveContext(task_id=docker_backend._task_id))\n+    # Belt and suspenders: the host secret must not appear anywhere in the error.\n+    assert b\"HOST-PRIVATE-KEY\".decode() not in str(excinfo.value)"]},{"file":"tests/tools/test_image_source.py","hunks":["@@ -0,0 +1,282 @@\n+\"\"\"Tests for tools/image_source.py — the unified vision image-source resolver.\n+\n+Covers the delivery contract (data:/http/file/local/container source handling,\n+size cap, magic-byte sniff) AND the terminal-backend confinement security model\n+(GHSA-gpxw-6wxv-w3qq): under a non-local backend, host reads are confined to the\n+media caches and every other path is read inside the sandbox via exec-read.\n+\"\"\"\n+\n+import base64\n+import importlib\n+from pathlib import Path\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+PNG = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * 64\n+JPEG = b\"\\xff\\xd8\\xff\" + b\"\\x00\" * 64\n+\n+\n+def _reload(monkeypatch, hermes_home: Path):\n+    monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+    import hermes_constants\n+    importlib.reload(hermes_constants)\n+    import tools.image_source as isrc\n+    importlib.reload(isrc)\n+    return isrc\n+\n+\n+class TestDataUrl:\n+    @pytest.mark.asyncio\n+    async def test_valid_data_url_resolves_to_bytes(self, tmp_path, monkeypatch):\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        b64 = base64.b64encode(PNG).decode()\n+        res = await isrc.resolve_image_source(\n+            f\"data:image/png;base64,{b64}\", isrc.ResolveContext())\n+        assert res.data == PNG\n+        assert res.mime == \"image/png\"\n+        assert res.origin == \"data\"\n+\n+    @pytest.mark.asyncio\n+    async def test_non_image_data_url_rejected(self, tmp_path, monkeypatch):\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        b64 = base64.b64encode(b\"not an image\").decode()\n+        with pytest.raises(isrc.NotAnImage):\n+            await isrc.resolve_image_source(\n+                f\"data:text/plain;base64,{b64}\", isrc.ResolveContext())\n+\n+\n+class TestLocalBackend:\n+    @pytest.mark.asyncio\n+    async def test_local_backend_reads_any_host_path(self, tmp_path, monkeypatch):\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+        img = tmp_path / \"outside\" / \"pic.png\"\n+        img.parent.mkdir(parents=True)\n+        img.write_bytes(PNG)\n+        res = await isrc.resolve_image_source(str(img), isrc.ResolveContext())\n+        assert res.data == PNG\n+        assert res.origin == \"file\"\n+\n+    @pytest.mark.asyncio\n+    async def test_file_uri_scheme_stripped(self, tmp_path, monkeypatch):\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+        img = tmp_path / \"pic.jpg\"\n+        img.write_bytes(JPEG)\n+        res = await isrc.resolve_image_source(f\"file://{img}\", isrc.ResolveContext())\n+        assert res.mime == \"image/jpeg\"\n+\n+    @pytest.mark.asyncio\n+    async def test_bare_relative_path_resolves(self, tmp_path, monkeypatch):\n+        \"\"\"A cwd-relative bare filename ('pic.png') is a valid local source —\n+        main accepted it; the resolver must not regress it (PR review).\"\"\"\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+        img = tmp_path / \"pic.png\"\n+        img.write_bytes(PNG)\n+        monkeypatch.chdir(tmp_path)\n+        res = await isrc.resolve_image_source(\"pic.png\", isrc.ResolveContext())\n+        assert res.data == PNG\n+        assert res.origin == \"file\"\n+\n+    @pytest.mark.asyncio\n+    async def test_unknown_url_scheme_rejected(self, tmp_path, monkeypatch):\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+        with pytest.raises(isrc.UnsupportedScheme):\n+            await isrc.resolve_image_source(\n+                \"ftp://example.com/pic.png\", isrc.ResolveContext())\n+\n+    @pytest.mark.asyncio\n+    async def test_svg_passes_through_for_rasterization(self, tmp_path, monkeypatch):\n+        \"\"\"SVG has no raster magic bytes but is passed through with mime\n+        image/svg+xml so the vision call sites can rasterize it to PNG.\"\"\"\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+        svg = tmp_path / \"art.svg\"\n+        svg_bytes = b'<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>'\n+        svg.write_bytes(svg_bytes)\n+        res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext())\n+        assert res.mime == \"image/svg+xml\"\n+        assert res.data == svg_bytes\n+\n+\n+class TestNonLocalBackendConfinement:\n+    \"\"\"The security model: under a sandbox backend, host reads are confined to\n+    the media caches; every other path is read inside the sandbox.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_media_cache_path_host_read(self, tmp_path, monkeypatch):\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+        cached = home / \"cache\" / \"images\" / \"inbound.png\"\n+        cached.parent.mkdir(parents=True)\n+        cached.write_bytes(PNG)\n+        # No sandbox env needed — a cache path is host-read directly.\n+        res = await isrc.resolve_image_source(str(cached), isrc.ResolveContext())\n+        assert res.data == PNG\n+        assert res.origin == \"file\"\n+\n+    @pytest.mark.asyncio\n+    async def test_host_secret_outside_cache_routes_to_sandbox_not_host(self, tmp_path, monkeypatch):\n+        \"\"\"A non-cache host path (e.g. /etc/passwd) must NOT be host-read — it\n+        routes to the in-sandbox exec-read, which reads the CONTAINER's file.\"\"\"\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+\n+        # A real host file outside the caches, holding a \"secret\".\n+        secret = [redacted:secret-assignment] / \"id_rsa\"\n+        secret.write_bytes(b\"HOST-PRIVATE-KEY-DO-NOT-LEAK\")\n+\n+        # Fake sandbox env: its exec-read returns a *different* (container) image,\n+        # proving we read the container filesystem, not the host secret.\n+        container_png_b64 = base64.b64encode(PNG).decode()\n+        calls = {}\n+\n+        def fake_execute(cmd, **kw):\n+            calls[\"cmd\"] = cmd\n+            return {\"returncode\": 0, \"output\": container_png_b64}\n+\n+        with patch(\"tools.image_source._get_active_env\",\n+                   return_value=SimpleNamespace(execute=fake_execute)):\n+            res = await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id=\"t1\"))\n+\n+        # Read came from the sandbox exec-read, returning the container image —\n+        # the host secret bytes never appear.\n+        assert res.origin == \"container\"\n+        assert res.data == PNG\n+        assert b\"HOST-PRIVATE-KEY\" not in res.data\n+        assert \"head -c\" in calls[\"cmd\"] and \"< \" in calls[\"cmd\"]  # bounded, redirect-safe form\n+\n+    @pytest.mark.asyncio\n+    async def test_non_cache_path_fails_closed_without_sandbox(self, tmp_path, monkeypatch):\n+        \"\"\"No active sandbox env -> refuse rather than fall back to a host read.\"\"\"\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+        secret = [redacted:secret-assignment] / \"id_rsa\"\n+        secret.write_bytes(b\"HOST-PRIVATE-KEY\")\n+\n+        with patch(\"tools.image_source._get_active_env\", return_value=None):\n+            with pytest.raises(isrc.SourceNotFound):\n+                await isrc.resolve_image_source(str(secret), isrc.ResolveContext(task_id=\"t1\"))\n+\n+    @pytest.mark.asyncio\n+    async def test_symlink_in_cache_pointing_outside_is_not_host_read(self, tmp_path, monkeypatch):\n+        \"\"\"A symlink planted inside a cache dir that points at a host secret must\n+        not be host-read (resolve() escapes the cache) — it routes to sandbox.\"\"\"\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+        secret = [redacted:secret-assignment] / \"outside\" / \"id_rsa\"\n+        secret.parent.mkdir(parents=True)\n+        secret.write_bytes(b\"HOST-PRIVATE-KEY\")\n+        cache_dir = home / \"cache\" / \"images\"\n+        cache_dir.mkdir(parents=True)\n+        link = cache_dir / \"sneaky.png\"\n+        try:\n+            link.symlink_to(secret)\n+        except (OSError, NotImplementedError):\n+            pytest.skip(\"symlinks unsupported\")\n+\n+        # Fails closed (no sandbox) rather than host-reading the symlink target.\n+        with patch(\"tools.image_source._get_active_env\", return_value=None):\n+            with pytest.raises(isrc.SourceNotFound):\n+                await isrc.resolve_image_source(str(link), isrc.ResolveContext(task_id=\"t1\"))\n+\n+\n+class TestExecReadSafety:\n+    @pytest.mark.asyncio\n+    async def test_exec_read_is_bounded_and_redirect_safe(self, tmp_path, monkeypatch):\n+        \"\"\"Leading-dash paths go through an input redirect (no argv exposure)\n+        and the read is size-bounded via head -c.\"\"\"\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+        captured = {}\n+\n+        def fake_execute(cmd, **kw):\n+            captured[\"cmd\"] = cmd\n+            return {\"returncode\": 0, \"output\": base64.b64encode(PNG).decode()}\n+\n+        with patch(\"tools.image_source._get_active_env\",\n+                   return_value=SimpleNamespace(execute=fake_execute)):\n+            await isrc.resolve_image_source(\n+                \"/workspace/-i-etc-shadow.png\", isrc.ResolveContext(task_id=\"t1\"))\n+        assert f\"head -c {isrc._MAX_INGEST_BYTES + 1} < \" in captured[\"cmd\"]\n+        assert \"'-i-etc-shadow.png'\" in captured[\"cmd\"] or \"-i-etc-shadow.png\" in captured[\"cmd\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_exec_read_over_cap_rejected(self, tmp_path, monkeypatch):\n+        \"\"\"A sandbox file larger than the ingest cap is rejected, not embedded.\"\"\"\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+        # head -c returns cap+1 bytes for an oversized file.\n+        over = base64.b64encode(b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * (isrc._MAX_INGEST_BYTES - 7)).decode()\n+\n+        def fake_execute(cmd, **kw):\n+            return {\"returncode\": 0, \"output\": over}\n+\n+        with patch(\"tools.image_source._get_active_env\",\n+                   return_value=SimpleNamespace(execute=fake_execute)):\n+            with pytest.raises(isrc.SourceTooLarge):\n+                await isrc.resolve_image_source(\n+                    \"/workspace/huge.png\", isrc.ResolveContext(task_id=\"t1\"))\n+\n+    @pytest.mark.asyncio\n+    async def test_exec_read_nonzero_returncode_raises(self, tmp_path, monkeypatch):\n+        home = tmp_path / \"hermes\"\n+        isrc = _reload(monkeypatch, home)\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"docker\")\n+\n+        def fake_execute(cmd, **kw):\n+            return {\"returncode\": 1, \"output\": \"\"}\n+\n+        with patch(\"tools.image_source._get_active_env\",\n+                   return_value=SimpleNamespace(execute=fake_execute)):\n+            with pytest.raises(isrc.SourceNotFound):\n+                await isrc.resolve_image_source(\n+                    \"/workspace/nope.png\", isrc.ResolveContext(task_id=\"t1\"))\n+\n+\n+class TestSvgNormalization:\n+    \"\"\"SVG resolves end-to-end: the resolver passes it through as\n+    image/svg+xml and the vision call sites rasterize it to PNG via\n+    _normalize_to_supported_image (PR #52688, folded in).\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_svg_rasterized_when_converter_available(self, tmp_path, monkeypatch):\n+        from tools import vision_tools as vt\n+        isrc = _reload(monkeypatch, tmp_path / \"hermes\")\n+        monkeypatch.setenv(\"TERMINAL_ENV\", \"local\")\n+        svg = tmp_path / \"art.svg\"\n+        svg.write_bytes(b'<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"4\"/>')\n+\n+        def fake_rasterize(svg_path, out_path):\n+            out_path.write_bytes(PNG)\n+            return True\n+\n+        with patch.object(vt, \"_rasterize_svg_to_png\", side_effect=fake_rasterize):\n+            res = await isrc.resolve_image_source(str(svg), isrc.ResolveContext())\n+            assert res.mime == \"image/svg+xml\"\n+            path, mime, err = vt._normalize_to_supported_image(svg, \"image/svg+xml\")\n+        assert err is None\n+        assert mime == \"image/png\"\n+        assert path.read_bytes() == PNG\n+        path.unlink()\n+\n+    def test_svg_actionable_error_when_no_converter(self, tmp_path, monkeypatch):\n+        from tools import vision_tools as vt\n+        _reload(monkeypatch, tmp_path / \"hermes\")\n+        svg = tmp_path / \"art.svg\"\n+        svg.write_bytes(b'<svg xmlns=\"http://www.w3.org/2000/svg\"/>')\n+        with patch.object(vt, \"_rasterize_svg_to_png\", return_value=False):\n+            path, mime, err = vt._normalize_to_supported_image(svg, \"image/svg+xml\")\n+        assert path is None\n+        assert \"rasterizer\" in err"]},{"file":"tests/tools/test_vision_native_fast_path.py","hunks":["@@ -119,7 +119,9 @@ def test_missing_file_returns_error_string(self, tmp_path):\n         assert isinstance(result, str)\n         parsed = json.loads(result)\n         assert parsed.get(\"success\") is False\n-        assert \"Invalid image source\" in parsed.get(\"error\", \"\")\n+        # Unified resolver: local backend reports a clean not-found.\n+        err = parsed.get(\"error\", \"\").lower()\n+        assert \"image file not found\" in err or \"no active sandbox\" in err\n \n     def test_empty_image_url_returns_error(self):\n         result = asyncio.get_event_loop().run_until_complete("]},{"file":"tests/tools/test_vision_tools.py","hunks":["@@ -1,5 +1,6 @@\n \"\"\"Tests for tools/vision_tools.py — URL validation, type hints, error logging.\"\"\"\n \n+import base64\n import json\n import logging\n import os","@@ -381,26 +382,20 @@ async def test_analysis_error_logs_exc_info(self, caplog):\n     @pytest.mark.asyncio\n     async def test_cleanup_error_logs_exc_info(self, tmp_path, caplog):\n         \"\"\"Temp file cleanup failure should log warning with exc_info.\"\"\"\n-        # Create a real temp file that will be \"downloaded\"\n-        temp_dir = tmp_path / \"temp_vision_images\"\n-        temp_dir.mkdir()\n-\n-        async def fake_download(url, dest, max_retries=3):\n-            \"\"\"Simulate download by writing file to the expected destination.\"\"\"\n-            dest.parent.mkdir(parents=True, exist_ok=True)\n-            dest.write_bytes(b\"\\xff\\xd8\\xff\" + b\"\\x00\" * 16)\n-            return dest\n+        # A data: URL resolves to bytes without any network, materializes to a\n+        # vision temp file, then the analysis runs — exercising the temp-cleanup\n+        # path in the finally block. A tiny valid JPEG (magic bytes) passes the\n+        # resolver's magic-byte sniff.\n+        jpeg_b64 = base64.b64encode(b\"\\xff\\xd8\\xff\" + b\"\\x00\" * 32).decode(\"ascii\")\n+        data_url = f\"data:image/jpeg;base64,{jpeg_b64}\"\n \n         with (\n-            patch(\"tools.vision_tools._validate_image_url_async\", new_callable=AsyncMock, return_value=True),\n-            patch(\"tools.vision_tools._download_image\", side_effect=fake_download),\n             patch(\n                 \"tools.vision_tools._image_to_base64_data_url\",\n                 return_value=\"data:image/jpeg;base64,abc\",\n             ),\n             caplog.at_level(logging.WARNING, logger=\"tools.vision_tools\"),\n         ):\n-            # Mock the async_call_llm function to return a mock response\n             mock_response = MagicMock()\n             mock_choice = MagicMock()\n             mock_choice.message.content = \"A test image description\"","@@ -409,16 +404,12 @@ async def fake_download(url, dest, max_retries=3):\n             with (\n                 patch(\"tools.vision_tools.async_call_llm\", new_callable=AsyncMock, return_value=mock_response),\n             ):\n-                # Make unlink fail to trigger cleanup warning\n-                original_unlink = Path.unlink\n-\n+                # Make unlink fail to trigger the cleanup warning.\n                 def failing_unlink(self, *args, **kwargs):\n                     raise PermissionError(\"no permission\")\n \n                 with patch.object(Path, \"unlink\", failing_unlink):\n-                    result = await vision_analyze_tool(\n-                        \"https://example.com/tempimg.jpg\", \"describe\", \"test/model\"\n-                    )\n+                    result = await vision_analyze_tool(data_url, \"describe\", \"test/model\")\n \n             warning_records = [\n                 r","@@ -500,7 +491,8 @@ async def test_local_non_image_file_rejected_before_llm_call(self, tmp_path):\n             result = json.loads(await vision_analyze_tool(str(secret), \"extract text\"))\n \n         assert result[\"success\"] is False\n-        assert \"Only real image files are supported\" in result[\"error\"]\n+        # The unified resolver's magic-byte sniff rejects non-images.\n+        assert \"not a recognized image\" in result[\"error\"]\n         mock_llm.assert_not_awaited()\n \n     @pytest.mark.asyncio","@@ -513,8 +505,8 @@ async def test_blocked_remote_url_short_circuits_before_download(self):\n         }\n \n         with (\n-            patch(\"tools.vision_tools.check_website_access\", return_value=blocked),\n-            patch(\"tools.vision_tools._validate_image_url_async\", new_callable=AsyncMock, return_value=True),\n+            patch(\"tools.website_policy.check_website_access\", return_value=blocked),\n+            patch(\"tools.url_safety.is_safe_url\", return_value=True),\n             patch(\"tools.vision_tools._download_image\", new_callable=AsyncMock) as mock_download,\n         ):\n             result = json.loads(await vision_analyze_tool(\"https://blocked.test/cat.png\", \"describe\"))","@@ -1258,7 +1250,7 @@ def slow_encode(path, mime_type=None):\n                     enc_inflight -= 1\n             return \"data:image/jpeg;base64,AAAA\"\n \n-        async def fake_native(image_url, question):\n+        async def fake_native(image_url, question, task_id=None):\n             nonlocal calls_inflight, calls_peak\n             calls_inflight += 1\n             calls_peak = max(calls_peak, calls_inflight)"]},{"file":"tools/credential_files.py","hunks":["@@ -402,6 +402,30 @@ def map_cache_path_to_container(\n     return None\n \n \n+def from_agent_visible_cache_path(\n+    container_path: str,\n+    container_base: str = \"/root/.hermes\",\n+) -> str:\n+    \"\"\"Translate a sandbox/container cache path back to its host path.\n+\n+    Inverse of :func:`to_agent_visible_cache_path`. Returns the input unchanged\n+    when the active backend is not Docker, or when the path is not under any\n+    auto-mounted cache directory — the caller then treats a still-container\n+    path as \"no host file\" and falls back to an in-container read.\n+    \"\"\"\n+    if os.environ.get(\"TERMINAL_ENV\", \"local\") != \"docker\":\n+        return container_path\n+\n+    path = Path(container_path)\n+    for mount in get_cache_directory_mounts(container_base=container_base):\n+        try:\n+            rel = path.relative_to(mount[\"container_path\"])\n+        except ValueError:\n+            continue\n+        return str(Path(mount[\"host_path\"]) / rel)\n+    return container_path\n+\n+\n def to_agent_visible_cache_path(\n     host_path: str,\n     container_base: str = \"/root/.hermes\","]},{"file":"tools/image_source.py","hunks":["@@ -0,0 +1,322 @@\n+\"\"\"Single resolver for every vision_analyze image source -> bytes + mime.\n+\n+All source handling (data:/http(s)/file/local/container) funnels through\n+:func:`resolve_image_source` so size and magic-byte checks are enforced exactly\n+once.  Returns raw bytes (not a path): the downstream step is base64 -> data URL\n+(RFC 2397) and provider base64 content blocks.\n+\n+Security (terminal-backend confinement, GHSA-gpxw-6wxv-w3qq): under a non-local\n+terminal backend the file tools are confined to the sandbox (SECURITY.md 2.2),\n+but vision read images host-side. This resolver enforces the same boundary:\n+\n+  * local backend            -> read any host path (chosen posture, unchanged)\n+  * non-local backend:\n+      path in a media cache   -> host-read (the gateway/download caches live on\n+                                 the host and are bind-mounted into the sandbox)\n+      path anywhere else      -> read the bytes *inside the sandbox* via exec-read\n+                                 (the agent can already ``cat`` any container file;\n+                                 this stays within the sandbox boundary and never\n+                                 reaches the host's ``/etc/passwd`` / ``~/.ssh``).\n+\n+So a prompt-injected ``vision_analyze('/etc/passwd')`` under Docker reads the\n+*container's* file (what every other tool sees), not the host's — no escape —\n+while container-only images (tmpfs ``/workspace``, root-owned) are still\n+deliverable. This is the unified delivery + confinement model: the same\n+mechanism that fixes \"vision can't see container files\" also closes the escape.\n+\"\"\"\n+from __future__ import annotations\n+\n+import asyncio\n+import base64\n+import os\n+import re\n+from dataclasses import dataclass\n+from pathlib import Path\n+from typing import Optional\n+\n+# Raw-bytes INGEST budget — what the resolver will load before handing off.\n+# This is deliberately the 50MB download cap (tools/vision_tools._VISION_MAX_DOWNLOAD_BYTES),\n+# NOT the 20MB provider payload cap. The 20MB cap (_MAX_BASE64_BYTES) is a\n+# *post-resize* limit enforced at the call sites: an oversized raw image must\n+# still reach the resizer so it can be downscaled under the payload cap. Capping\n+# raw bytes at 20MB here would reject every 20-50MB photo before resize can run.\n+_MAX_INGEST_BYTES = 50 * 1024 * 1024\n+\n+\n+class ImageResolutionError(Exception):\n+    def __init__(self, message: str, *, src: str = \"\", origin: str = \"\"):\n+        super().__init__(message)\n+        self.src, self.origin = src, origin\n+\n+\n+class UnsupportedScheme(ImageResolutionError):\n+    pass\n+\n+\n+class SourceUnsafe(ImageResolutionError):  # SSRF / path-allowlist\n+    pass\n+\n+\n+class SourceTooLarge(ImageResolutionError):\n+    pass\n+\n+\n+class SourceNotFound(ImageResolutionError):\n+    pass\n+\n+\n+class NotAnImage(ImageResolutionError):\n+    pass\n+\n+\n+@dataclass\n+class ResolveContext:\n+    task_id: Optional[str] = None\n+\n+\n+@dataclass\n+class ResolvedImage:\n+    data: bytes\n+    mime: str\n+    origin: str  # one of: data | http | file | local | container\n+\n+\n+# Explicit URL scheme, e.g. \"ftp://\", \"s3://\". Bare Windows drive paths\n+# (\"C:\\x.png\") don't match because they lack the \"//\".\n+_SCHEME_RE = re.compile(r\"^[A-Za-z][A-Za-z0-9+.\\-]*://\")\n+\n+\n+async def resolve_image_source(src: str, ctx: ResolveContext) -> ResolvedImage:\n+    if not isinstance(src, str) or not src.strip():\n+        raise SourceNotFound(\"image_url is required\", src=str(src))\n+    s = src.strip()\n+    if s.startswith(\"data:\"):\n+        data, mime = _resolve_data_url(s)\n+        return _finalize(data, mime, \"data\", s)\n+    if s.startswith((\"http://\", \"https://\")):\n+        reason = _http_block_reason(s)\n+        if reason:\n+            raise SourceUnsafe(reason, src=s)\n+        return _finalize(await _download_to_bytes(s), \"\", \"http\", s)\n+\n+    if _SCHEME_RE.match(s) and not s.lower().startswith(\"file://\"):\n+        raise UnsupportedScheme(\n+            \"Unrecognized image source scheme. Use an http(s) URL, a local \"\n+            \"file path, a file:// URI, or a data: URL.\",\n+            src=s,\n+        )\n+\n+    # Everything else is a filesystem path — including bare relative names\n+    # like \"pic.png\" (accepted on main; a path-shape gate here regressed them).\n+    candidate = s[len(\"file://\"):] if s.lower().startswith(\"file://\") else s\n+    p = Path(os.path.expanduser(candidate))\n+    # Confinement decision (see module docstring). Under a non-local backend\n+    # a path is host-readable ONLY if it lands in a media cache (after\n+    # translating a container-visible cache path back to its host mount);\n+    # every other path is read inside the sandbox via exec-read, so a host\n+    # path outside the caches never yields the host's bytes.\n+    host_target = _permitted_host_read_target(p, ctx)\n+    if host_target is not None and host_target.is_file():\n+        data = await asyncio.to_thread(host_target.read_bytes)\n+        return _finalize(data, \"\", \"file\", s)\n+    if _is_local_terminal_backend():\n+        # Local backend: any path was host-readable, so a miss simply means\n+        # the file doesn't exist — no sandbox to fall back to.\n+        raise SourceNotFound(f\"image file not found: '{p}'\", src=s, origin=\"file\")\n+    # Not a permitted host read (or the host file is absent) -> read the\n+    # bytes inside the sandbox. Under a sandbox this reads the container's\n+    # filesystem, never the host's.\n+    return await _resolve_container_fallback(p, ctx, s)\n+\n+\n+def _resolve_data_url(s: str) -> tuple[bytes, str]:\n+    header, _, payload = s.partition(\",\")\n+    if \";base64\" not in header:\n+        raise NotAnImage(\"data: URL must be base64-encoded\", src=s[:64])\n+    declared = header[len(\"data:\"):].split(\";\", 1)[0].strip() or \"application/octet-stream\"\n+    # Cheap pre-decode size gate on the encoded length (~4/3 expansion).\n+    if (len(payload) * 3) // 4 > _MAX_INGEST_BYTES:\n+        raise SourceTooLarge(\"data: URL exceeds size limit\", src=s[:64])\n+    try:\n+        data = base64.b64decode(payload, validate=True)\n+    except Exception as exc:\n+        raise NotAnImage(f\"invalid base64 in data: URL: {exc}\", src=s[:64])\n+    return data, declared  # real mime verified in _finalize via magic bytes\n+\n+\n+def _http_block_reason(url: str) -> Optional[str]:\n+    \"\"\"Return a human-readable block reason, or None when the URL is allowed.\n+\n+    Pre-flight short-circuit: policy-blocked URLs are refused BEFORE any\n+    network I/O. ``_download_image`` re-checks policy internally (per attempt\n+    and against the final redirect target) — that second evaluation is\n+    intentional, not redundant: this one guarantees no bytes move for a\n+    blocked URL; the inner one covers redirects and non-resolver callers.\n+    Preserves the specific website-policy message so the agent sees *why*.\n+    \"\"\"\n+    from tools.url_safety import is_safe_url\n+    from tools.website_policy import check_website_access\n+\n+    if not is_safe_url(url):\n+        return \"blocked: unsafe or private URL\"\n+    blocked = check_website_access(url)\n+    if blocked:\n+        return blocked.get(\"message\") or \"blocked by website policy\"\n+    return None\n+\n+\n+async def _download_to_bytes(url: str) -> bytes:\n+    import tempfile\n+\n+    from tools.vision_tools import _download_image\n+\n+    with tempfile.NamedTemporaryFile(suffix=\".img\", delete=False) as tf:\n+        tmp = Path(tf.name)\n+    try:\n+        # Enforces the 50MB stream cap, redirect SSRF guard, and website policy.\n+        await _download_image(url, tmp)\n+        return await asyncio.to_thread(tmp.read_bytes)\n+    except PermissionError as exc:  # website policy block\n+        raise SourceUnsafe(str(exc), src=url, origin=\"http\")\n+    finally:\n+        tmp.unlink(missing_ok=True)\n+\n+\n+def _is_local_terminal_backend() -> bool:\n+    \"\"\"True when the terminal backend runs directly on the host.\n+\n+    Mirrors ``tools.browser_tool._is_local_backend`` and terminal_tool's own\n+    dispatch, which key off ``TERMINAL_ENV``.\n+    \"\"\"\n+    return os.getenv(\"TERMINAL_ENV\", \"local\").strip().lower() in (\"local\", \"\")\n+\n+\n+def _media_cache_roots() -> list:\n+    \"\"\"Agent-managed media cache directories under HERMES_HOME (host side).\n+\n+    The only host paths vision may read under a non-local backend: gateway-\n+    downloaded inbound media and the tools' own URL-download temp dirs. Covers\n+    the consolidated ``cache/`` layout and the legacy flat directories.\n+    \"\"\"\n+    from hermes_constants import get_hermes_home\n+\n+    home = get_hermes_home()\n+    return [\n+        home / \"cache\",  # cache/images, cache/vision, cache/video(s), cache/audio\n+        home / \"image_cache\",\n+        home / \"audio_cache\",\n+        home / \"video_cache\",\n+        home / \"temp_vision_images\",\n+        home / \"temp_video_files\",\n+    ]\n+\n+\n+def _permitted_host_read_target(p: Path, ctx: ResolveContext) -> Optional[Path]:\n+    \"\"\"Return the host path to read, or ``None`` if a host read is not permitted.\n+\n+    - Local backend: any path is permitted (chosen posture). Returns ``p``.\n+    - Non-local backend: permitted only if the path resolves inside a media\n+      cache root. A container-visible cache path (e.g. ``/root/.hermes/cache/\n+      images/x.png``) is first translated back to its host mount; anything that\n+      is not under a cache returns ``None`` so the caller routes it to the\n+      in-sandbox exec-read instead of reading the host filesystem.\n+    \"\"\"\n+    if _is_local_terminal_backend():\n+        try:\n+            return p.resolve()\n+        except Exception:  # noqa: BLE001 — unresolved path: let is_file() fail downstream\n+            return p\n+\n+    from tools.credential_files import from_agent_visible_cache_path\n+\n+    host_candidate = Path(from_agent_visible_cache_path(str(p)))\n+    try:\n+        real = host_candidate.resolve()\n+    except Exception:  # noqa: BLE001 — cannot resolve -> not a safe host read\n+        return None\n+    for root in _media_cache_roots():\n+        try:\n+            real.relative_to(root.resolve())\n+            return real\n+        except ValueError:\n+            continue\n+    return None\n+\n+\n+def _get_active_env(task_id: Optional[str]):\n+    if not task_id:\n+        return None\n+    try:\n+        from tools.terminal_tool import get_active_env\n+\n+        return get_active_env(task_id)\n+    except Exception:\n+        return None\n+\n+\n+async def _resolve_container_fallback(p: Path, ctx: ResolveContext, src: str) -> ResolvedImage:\n+    \"\"\"Read the image bytes inside the sandbox (fail-closed when none exists).\n+\n+    Reached when a host read is not permitted or the host file is absent. The\n+    agent can already ``cat`` any container file (file_operations.py reads\n+    root-owned mode-600 files this way), so this stays within the same sandbox\n+    boundary and never touches the host filesystem. ``--`` stops a leading-dash\n+    path from being parsed as a ``base64`` option; ``base64 -w0`` is GNU-only,\n+    so pipe through ``tr -d`` for BusyBox.\n+\n+    Fail-closed: if there is no active sandbox env we refuse rather than falling\n+    back to a host read, so a non-cache host path under a sandbox never leaks.\n+    \"\"\"\n+    import asyncio\n+    import shlex\n+\n+    env = _get_active_env(ctx.task_id)\n+    if env is None:\n+        raise SourceNotFound(\n+            f\"'{p}' is not reachable inside the sandbox and no active sandbox \"\n+            f\"session is available to read it\",\n+            src=src, origin=\"container\")\n+\n+    # Bound the read INSIDE the sandbox: head -c caps at ingest-limit+1 bytes\n+    # so a huge file (or /dev/zero) can't stream unbounded base64 into host\n+    # memory — the +1 byte lets us distinguish \"exactly at the cap\" from\n+    # \"over the cap\" after decode. The input redirect (< path) avoids argv\n+    # entirely, so leading-dash paths can't be parsed as options; base64\n+    # -w0 is GNU-only, so pipe through tr -d for BusyBox.\n+    # env.execute is a blocking backend exec; keep it off the event loop so a\n+    # multi-MB base64 read doesn't stall every other coroutine.\n+    qp = shlex.quote(str(p))\n+    res = await asyncio.to_thread(\n+        env.execute,\n+        f\"head -c {_MAX_INGEST_BYTES + 1} < {qp} | base64 | tr -d '\\\\n'\")\n+    if res.get(\"returncode\", 1) != 0:\n+        raise SourceNotFound(f\"could not read '{p}' inside the sandbox\", src=src, origin=\"container\")\n+    try:\n+        data = base64.b64decode(res.get(\"output\", \"\"), validate=True)\n+    except Exception as exc:\n+        raise NotAnImage(f\"sandbox returned non-image data for '{p}': {exc}\", src=src)\n+    if len(data) > _MAX_INGEST_BYTES:\n+        raise SourceTooLarge(\"image exceeds size limit\", src=src, origin=\"container\")\n+    return _finalize(data, \"\", \"container\", src)\n+\n+\n+def _finalize(data: bytes, declared_mime: str, origin: str, src: str) -> ResolvedImage:\n+    \"\"\"Intrinsic-correctness chokepoint: ingest byte cap + magic-byte sniff.\n+\n+    The cap here is the generous 50MB *ingest* budget, not the 20MB provider\n+    payload cap — a 20-50MB image must survive this step so the call site can\n+    resize it under the payload cap. See ``_MAX_INGEST_BYTES``.\n+    \"\"\"\n+    from tools.vision_tools import _detect_image_mime_type_from_bytes\n+\n+    if len(data) > _MAX_INGEST_BYTES:\n+        raise SourceTooLarge(\"image exceeds size limit\", src=src, origin=origin)\n+    sniffed = _detect_image_mime_type_from_bytes(data)\n+    if sniffed is None:\n+        if b\"<svg\" in data[:4096].lower():\n+            # Pass SVG through — the vision call sites rasterize it to PNG\n+            # via _normalize_to_supported_image before embedding (providers\n+            # only ingest raster images).\n+            return ResolvedImage(data=data, mime=\"image/svg+xml\", origin=origin)\n+        raise NotAnImage(\"source is not a recognized image\", src=src, origin=origin)\n+    return ResolvedImage(data=data, mime=sniffed, origin=origin)"]},{"file":"tools/vision_tools.py","hunks":["@@ -221,11 +221,14 @@ async def _validate_image_url_async(url: str) -> bool:\n     return await async_is_safe_url(url)\n \n \n-def _detect_image_mime_type(image_path: Path) -> Optional[str]:\n-    \"\"\"Return a MIME type when the file looks like a supported image.\"\"\"\n-    with image_path.open(\"rb\") as f:\n-        header = f.read(64)\n+def _detect_image_mime_type_from_bytes(data: bytes) -> Optional[str]:\n+    \"\"\"Magic-byte MIME sniff on raw bytes (authoritative; no extension trust).\n \n+    Returns ``None`` for anything without a recognized image header — including\n+    SVG, which has no magic bytes. The resolver special-cases SVG (sniffs\n+    ``<svg``) and passes it through for rasterization at the call sites.\n+    \"\"\"\n+    header = data[:64]\n     if header.startswith(b\"\\x89PNG\\r\\n\\x1a\\n\"):\n         return \"image/png\"\n     if header.startswith(b\"\\xff\\xd8\\xff\"):","@@ -236,13 +239,125 @@ def _detect_image_mime_type(image_path: Path) -> Optional[str]:\n         return \"image/bmp\"\n     if len(header) >= 12 and header[:4] == b\"RIFF\" and header[8:12] == b\"WEBP\":\n         return \"image/webp\"\n-    if image_path.suffix.lower() == \".svg\":\n-        head = image_path.read_text(encoding=\"utf-8\", errors=\"ignore\")[:4096].lower()\n-        if \"<svg\" in head:\n-            return \"image/svg+xml\"\n     return None\n \n \n+# Media types the major vision providers (Anthropic in particular) accept for\n+# inline base64 images.  Anything outside this set — SVG, BMP, TIFF, etc. — is\n+# rejected with a non-retryable 400.  Because a vision tool-result is baked into\n+# immutable conversation history and re-sent every turn, embedding an\n+# unsupported media_type permanently wedges the session (retries re-send the\n+# same bad bytes).  We MUST normalize to one of these before embedding.\n+_ANTHROPIC_SUPPORTED_MEDIA_TYPES = frozenset(\n+    {\"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\"}\n+)\n+\n+\n+def _rasterize_svg_to_png(svg_path: Path, out_path: Path) -> bool:\n+    \"\"\"Best-effort SVG → PNG rasterization. Returns True on success.\n+\n+    Tries, in order: cairosvg, svglib+reportlab, then system rasterizers\n+    (rsvg-convert, inkscape).  All are soft dependencies; if none is available\n+    we return False and the caller rejects the image with an actionable error\n+    rather than embedding an unsupported media_type that would wedge the\n+    session.\n+    \"\"\"\n+    # 1) cairosvg (pure-python-ish, most common)\n+    try:\n+        import cairosvg  # type: ignore\n+        cairosvg.svg2png(url=str(svg_path), write_to=str(out_path))\n+        return out_path.exists() and out_path.stat().st_size > 0\n+    except Exception:\n+        pass\n+    # 2) svglib + reportlab\n+    try:\n+        from svglib.svglib import svg2rlg  # type: ignore\n+        from reportlab.graphics import renderPM  # type: ignore\n+        drawing = svg2rlg(str(svg_path))\n+        if drawing is not None:\n+            renderPM.drawToFile(drawing, str(out_path), fmt=\"PNG\")\n+            return out_path.exists() and out_path.stat().st_size > 0\n+    except Exception:\n+        pass\n+    # 3) system rasterizers\n+    import shutil as _shutil\n+    import subprocess as _subprocess\n+    for cmd in (\n+        [\"rsvg-convert\", \"-o\", str(out_path), str(svg_path)],\n+        [\"inkscape\", str(svg_path), \"--export-type=png\",\n+         f\"--export-filename={out_path}\"],\n+    ):\n+        if _shutil.which(cmd[0]):\n+            try:\n+                _subprocess.run(\n+                    cmd, check=True, capture_output=True, timeout=30,\n+                    stdin=_subprocess.DEVNULL,\n+                )\n+                if out_path.exists() and out_path.stat().st_size > 0:\n+                    return True\n+            except Exception:\n+                continue\n+    return False\n+\n+\n+def _normalize_to_supported_image(\n+    image_path: Path, detected_mime: str\n+) -> tuple[Optional[Path], Optional[str], Optional[str]]:\n+    \"\"\"Ensure an image is in a vision-provider-supported format.\n+\n+    Returns a 3-tuple ``(path, mime, error)``:\n+      - If ``detected_mime`` is already supported: ``(image_path, detected_mime, None)``.\n+      - If conversion succeeds: ``(new_png_path, \"image/png\", None)`` — the new\n+        path is a temp file the CALLER must clean up.\n+      - If conversion is impossible: ``(None, None, <error message>)``.\n+\n+    SVG is rasterized to PNG (best-effort, soft deps).  Other raster formats\n+    Pillow can read (BMP, TIFF, etc.) are re-encoded to PNG.  This runs BEFORE\n+    the image is base64-embedded into conversation history, so an unsupported\n+    media_type can never reach the provider and wedge the session.\n+    \"\"\"\n+    if detected_mime in _ANTHROPIC_SUPPORTED_MEDIA_TYPES:\n+        return image_path, detected_mime, None\n+\n+    out_dir = get_hermes_dir(\"cache/vision\", \"temp_vision_images\")\n+    out_dir.mkdir(parents=True, exist_ok=True)\n+    out_path = out_dir / f\"converted_{uuid.uuid4()}.png\"\n+\n+    # SVG: needs a rasterizer (Pillow cannot render SVG).\n+    if detected_mime == \"image/svg+xml\":\n+        if _rasterize_svg_to_png(image_path, out_path):\n+            return out_path, \"image/png\", None\n+        return (\n+            None,\n+            None,\n+            \"This is an SVG, which vision models cannot read directly, and no \"\n+            \"SVG rasterizer is installed (tried cairosvg, svglib, rsvg-convert, \"\n+            \"inkscape). Convert the SVG to PNG first — e.g. open it in a browser \"\n+            \"and screenshot it, or install a rasterizer \"\n+            \"(`pip install cairosvg`) — then re-run vision_analyze on the PNG.\",\n+        )\n+\n+    # Other non-supported raster formats (BMP, TIFF, ...): re-encode via Pillow.\n+    try:\n+        from PIL import Image as _PILImage\n+        with _PILImage.open(image_path) as _img:\n+            if _img.mode not in (\"RGB\", \"RGBA\", \"L\"):\n+                _img = _img.convert(\"RGBA\")\n+            _img.save(out_path, format=\"PNG\")\n+        if out_path.exists() and out_path.stat().st_size > 0:\n+            return out_path, \"image/png\", None\n+    except Exception as _exc:\n+        logger.warning(\"Failed to normalize %s image to PNG: %s\",\n+                       detected_mime, _exc)\n+    return (\n+        None,\n+        None,\n+        f\"Image format {detected_mime!r} is not supported by the vision API \"\n+        f\"and could not be converted to PNG (install Pillow for raster \"\n+        f\"conversion). Convert it to PNG or JPEG and try again.\",\n+    )\n+\n+\n def _is_retryable_download_error(error: Exception) -> bool:\n     \"\"\"Return True only for transient image-download failures worth retrying.\n ","@@ -817,13 +932,14 @@ async def _vision_concurrency_slot():\n async def _vision_analyze_native(\n     image_url: str,\n     question: str,\n+    task_id: Optional[str] = None,\n ) -> Any:\n     \"\"\"Fast path for vision-capable main models.\n \n-    Loads the image (local file OR remote URL), base64-encodes it, and\n-    returns a multimodal tool-result envelope. The agent loop unwraps it;\n-    provider adapters serialize it into the right tool-result-with-image\n-    shape for each backend.\n+    Loads the image (data: / http(s) / file:// / local path / sandbox-container\n+    path) via the unified resolver, base64-encodes it, and returns a multimodal\n+    tool-result envelope. The agent loop unwraps it; provider adapters serialize\n+    it into the right tool-result-with-image shape for each backend.\n \n     Returns:\n         A ``_multimodal`` envelope dict on success.","@@ -840,38 +956,51 @@ async def _vision_analyze_native(\n         if is_interrupted():\n             return tool_error(\"Interrupted\", success=False)\n \n-        # Resolve the image source (mirrors vision_analyze_tool's logic\n-        # exactly so behaviour is consistent).\n-        resolved_url = image_url\n-        if resolved_url.startswith(\"file://\"):\n-            resolved_url = resolved_url[len(\"file://\"):]\n-        local_path = Path(os.path.expanduser(resolved_url))\n-\n-        if local_path.is_file():\n-            temp_image_path = local_path\n-            should_cleanup = False\n-        elif await _validate_image_url_async(image_url):\n-            blocked = check_website_access(image_url)\n-            if blocked:\n-                return tool_error(blocked[\"message\"], success=False)\n-            temp_dir = get_hermes_dir(\"cache/vision\", \"temp_vision_images\")\n-            temp_image_path = temp_dir / f\"temp_image_{uuid.uuid4()}.jpg\"\n-            await _download_image(image_url, temp_image_path)\n-            should_cleanup = True\n-        else:\n-            return tool_error(\n-                \"Invalid image source. Provide an HTTP/HTTPS URL or a \"\n-                \"valid local file path.\",\n-                success=False,\n-            )\n+        # Resolve the source to raw bytes through the single resolver (unifies\n+        # data:/http/file/local/container and enforces terminal-backend\n+        # confinement). Materialize to a temp file so the existing path-based\n+        # encode/resize/embed-cap pipeline below is reused verbatim.\n+        from tools.image_source import (\n+            ImageResolutionError,\n+            ResolveContext,\n+            resolve_image_source,\n+        )\n \n-        image_size_bytes = temp_image_path.stat().st_size\n-        detected_mime_type = _detect_image_mime_type(temp_image_path)\n-        if not detected_mime_type:\n+        try:\n+            resolved = await resolve_image_source(image_url, ResolveContext(task_id=task_id))\n+        except ImageResolutionError as exc:\n+            return tool_error(str(exc), success=False)\n+\n+        detected_mime_type = resolved.mime\n+        image_size_bytes = len(resolved.data)\n+        temp_dir = get_hermes_dir(\"cache/vision\", \"temp_vision_images\")\n+        temp_dir.mkdir(parents=True, exist_ok=True)\n+        temp_image_path = temp_dir / f\"temp_image_{uuid.uuid4()}.img\"\n+        await asyncio.to_thread(temp_image_path.write_bytes, resolved.data)\n+        should_cleanup = True\n+\n+        # Normalize unsupported formats (SVG, BMP, ...) to PNG BEFORE embedding.\n+        # Anthropic only accepts jpeg/png/gif/webp; an unsupported media_type\n+        # baked into immutable history wedges the session with a 400 on every\n+        # resume.  Convert here so it can never enter history. Offloaded — the\n+        # rasterizers/Pillow are blocking.\n+        normalized_path, detected_mime_type, _norm_err = await asyncio.to_thread(\n+            _normalize_to_supported_image, temp_image_path, detected_mime_type,\n+        )\n+        if _norm_err or normalized_path is None:\n             return tool_error(\n-                \"Only real image files are supported for vision analysis.\",\n-                success=False,\n+                _norm_err or \"Image normalization failed.\", success=False,\n             )\n+        if normalized_path != temp_image_path:\n+            # We created a temp PNG — swap to it and ensure it's cleaned up.\n+            if should_cleanup and temp_image_path.exists():\n+                try:\n+                    temp_image_path.unlink()\n+                except Exception:\n+                    pass\n+            temp_image_path = normalized_path\n+            should_cleanup = True\n+            image_size_bytes = temp_image_path.stat().st_size\n \n         image_data_url = await _run_encode_on_cpu_executor(\n             _image_to_base64_data_url,","@@ -935,6 +1064,7 @@ async def vision_analyze_tool(\n     image_url: str,\n     user_prompt: str,\n     model: str = None,\n+    task_id: Optional[str] = None,\n ) -> str:\n     \"\"\"\n     Analyze an image from a URL or local file path using vision AI.","@@ -996,42 +1126,50 @@ async def vision_analyze_tool(\n \n         logger.info(\"Analyzing image: %s\", image_url[:60])\n         logger.info(\"User prompt: %s\", user_prompt[:100])\n-        \n-        # Determine if this is a local file path or a remote URL\n-        # Strip file:// scheme so file URIs resolve as local paths.\n-        resolved_url = image_url\n-        if resolved_url.startswith(\"file://\"):\n-            resolved_url = resolved_url[len(\"file://\"):]\n-        local_path = Path(os.path.expanduser(resolved_url))\n-        if local_path.is_file():\n-            # Local file path (e.g. from platform image cache) -- skip download\n-            logger.info(\"Using local image file: %s\", image_url)\n-            temp_image_path = local_path\n-            should_cleanup = False  # Don't delete cached/local files\n-        elif await _validate_image_url_async(image_url):\n-            # Remote URL -- download to a temporary location\n-            blocked = check_website_access(image_url)\n-            if blocked:\n-                raise PermissionError(blocked[\"message\"])\n-            logger.info(\"Downloading image from URL...\")\n-            temp_dir = get_hermes_dir(\"cache/vision\", \"temp_vision_images\")\n-            temp_image_path = temp_dir / f\"temp_image_{uuid.uuid4()}.jpg\"\n-            await _download_image(image_url, temp_image_path)\n-            should_cleanup = True\n-        else:\n-            raise ValueError(\n-                \"Invalid image source. Provide an HTTP/HTTPS URL or a valid local file path.\"\n-            )\n-        \n+\n+        # Resolve the source to raw bytes through the single resolver (unifies\n+        # data:/http/file/local/container and enforces terminal-backend\n+        # confinement). Materialize to a temp file so the existing path-based\n+        # encode/resize pipeline below is reused verbatim.\n+        from tools.image_source import (\n+            ImageResolutionError,\n+            ResolveContext,\n+            resolve_image_source,\n+        )\n+\n+        try:\n+            resolved = await resolve_image_source(image_url, ResolveContext(task_id=task_id))\n+        except ImageResolutionError as exc:\n+            raise ValueError(str(exc))\n+\n+        detected_mime_type = resolved.mime\n+        temp_dir = get_hermes_dir(\"cache/vision\", \"temp_vision_images\")\n+        temp_dir.mkdir(parents=True, exist_ok=True)\n+        temp_image_path = temp_dir / f\"temp_image_{uuid.uuid4()}.img\"\n+        await asyncio.to_thread(temp_image_path.write_bytes, resolved.data)\n+        should_cleanup = True\n+\n         # Get image file size for logging\n-        image_size_bytes = temp_image_path.stat().st_size\n+        image_size_bytes = len(resolved.data)\n         image_size_kb = image_size_bytes / 1024\n         logger.info(\"Image ready (%.1f KB)\", image_size_kb)\n+        # Normalize unsupported formats (SVG, BMP, ...) to PNG. Vision providers\n+        # reject these media types; convert before encoding. Offloaded — the\n+        # rasterizers/Pillow are blocking.\n+        normalized_path, detected_mime_type, _norm_err = await asyncio.to_thread(\n+            _normalize_to_supported_image, temp_image_path, detected_mime_type,\n+        )\n+        if _norm_err or normalized_path is None:\n+            raise ValueError(_norm_err or \"Image normalization failed.\")\n+        if normalized_path != temp_image_path:\n+            if should_cleanup and temp_image_path.exists():\n+                try:\n+                    temp_image_path.unlink()\n+                except Exception:\n+                    pass\n+            temp_image_path = normalized_path\n+            should_cleanup = True\n \n-        detected_mime_type = _detect_image_mime_type(temp_image_path)\n-        if not detected_mime_type:\n-            raise ValueError(\"Only real image files are supported for vision analysis.\")\n-        \n         # Convert image to base64 — send at full resolution first.\n         # If the provider rejects it as too large, we auto-resize and retry.\n         # Offloaded to the bounded vision CPU executor so a fan-out of encodes","@@ -1335,6 +1473,7 @@ def check_vision_requirements() -> bool:\n async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:\n     image_url = args.get(\"image_url\", \"\")\n     question = args.get(\"question\", \"\")\n+    task_id = kw.get(\"task_id\")\n \n     # The fan-out cap lives inside the encode/resize step (offloaded to the\n     # bounded _vision_cpu_executor), NOT around the whole analysis — so a","@@ -1349,7 +1488,7 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:\n     # information loss, no extra latency.\n     if _should_use_native_vision_fast_path():\n         logger.info(\"vision_analyze: native fast path\")\n-        return await _vision_analyze_native(image_url, question)\n+        return await _vision_analyze_native(image_url, question, task_id=task_id)\n \n     # Legacy path: aux LLM describes the image and we return its text.\n     full_prompt = (","@@ -1368,7 +1507,7 @@ async def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> str:\n         pass\n     if not model:\n         model = os.getenv(\"AUXILIARY_VISION_MODEL\", \"\").strip() or None\n-    return await vision_analyze_tool(image_url, full_prompt, model)\n+    return await vision_analyze_tool(image_url, full_prompt, model, task_id=task_id)\n \n \n registry.register(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}