From 9ed3905a23b62ce9aa65eb038a712cd2d493609c Mon Sep 17 00:00:00 2001 From: michaelxer Date: Thu, 18 Jun 2026 17:05:02 +0700 Subject: [PATCH] fix(providers): use non-descriptive placeholder when stripping images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image-strip fallback (triggered when a model errors on image input) replaced image_url blocks with [image: ] or [image omitted]. Both read like a live, available image to the LLM, causing it to: 1. hallucinate about image contents it never received 2. attempt read_file on the leaked server path 3. expose internal file paths to the model Replace with an explicit '[Image not delivered to model — do not describe or reference it]' placeholder that tells the LLM the image was stripped. Fixes #4345 --- nanobot/providers/base.py | 14 +- tests/providers/test_strip_image_content.py | 173 ++++++++++++++++++++ 2 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 tests/providers/test_strip_image_content.py diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 7757eb65..b2a93ded 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -15,8 +15,6 @@ from typing import Any import json_repair from loguru import logger -from nanobot.utils.helpers import image_placeholder_text - STREAM_IDLE_TIMEOUT_ENV = "NANOBOT_STREAM_IDLE_TIMEOUT_S" DEFAULT_STREAM_IDLE_TIMEOUT_S = 90.0 MAX_STREAM_IDLE_TIMEOUT_S = 3600.0 @@ -564,8 +562,10 @@ class LLMProvider(ABC): new_content = [] for b in content: if isinstance(b, dict) and b.get("type") == "image_url": - path = (b.get("_meta") or {}).get("path", "") - placeholder = image_placeholder_text(path, empty="[image omitted]") + placeholder = ( + "[Image not delivered to model — " + "do not describe or reference it]" + ) new_content.append({"type": "text", "text": placeholder}) found = True else: @@ -589,8 +589,10 @@ class LLMProvider(ABC): if isinstance(content, list): for i, b in enumerate(content): if isinstance(b, dict) and b.get("type") == "image_url": - path = (b.get("_meta") or {}).get("path", "") - placeholder = image_placeholder_text(path, empty="[image omitted]") + placeholder = ( + "[Image not delivered to model — " + "do not describe or reference it]" + ) content[i] = {"type": "text", "text": placeholder} found = True return found diff --git a/tests/providers/test_strip_image_content.py b/tests/providers/test_strip_image_content.py new file mode 100644 index 00000000..6f51339b --- /dev/null +++ b/tests/providers/test_strip_image_content.py @@ -0,0 +1,173 @@ +"""Tests for LLMProvider._strip_image_content and _strip_image_content_inplace. + +Regression test for #4345: image-strip fallback should not leak file paths +or produce text that makes the model hallucinate about unseen images. +""" + +from __future__ import annotations + +from nanobot.providers.base import LLMProvider + + +# --------------------------------------------------------------------------- +# _strip_image_content (returns new list) +# --------------------------------------------------------------------------- + + +class TestStripImageContent: + """Tests for the non-mutating _strip_image_content method.""" + + def test_replaces_image_url_with_nondescriptive_placeholder(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + "_meta": {"path": "/tmp/screenshot.png"}, + }, + ], + } + ] + result = LLMProvider._strip_image_content(messages) + assert result is not None + content = result[0]["content"] + assert len(content) == 2 + assert content[0] == {"type": "text", "text": "What is this?"} + assert content[1]["type"] == "text" + placeholder = content[1]["text"] + # Must NOT leak the file path + assert "/tmp/screenshot.png" not in placeholder + assert "screenshot" not in placeholder + # Must clearly indicate the image was not delivered + assert "not delivered" in placeholder.lower() or "not describe" in placeholder.lower() + + def test_returns_none_when_no_images(self): + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]} + ] + assert LLMProvider._strip_image_content(messages) is None + + def test_handles_multiple_images(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Look at these:"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + "_meta": {"path": "/a.png"}, + }, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,def"}, + "_meta": {"path": "/b.png"}, + }, + ], + } + ] + result = LLMProvider._strip_image_content(messages) + assert result is not None + content = result[0]["content"] + # text + 2 placeholders + assert len(content) == 3 + for block in content[1:]: + assert block["type"] == "text" + assert "/a.png" not in block["text"] + assert "/b.png" not in block["text"] + + def test_handles_image_without_meta(self): + messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + }, + ], + } + ] + result = LLMProvider._strip_image_content(messages) + assert result is not None + placeholder = result[0]["content"][0]["text"] + assert "[image" not in placeholder.lower() or "not delivered" in placeholder.lower() + + def test_preserves_non_image_content(self): + messages = [ + {"role": "system", "content": "You are helpful."}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + "_meta": {"path": "/x.png"}, + }, + ], + }, + {"role": "assistant", "content": "Hi there!"}, + ] + result = LLMProvider._strip_image_content(messages) + assert result is not None + # system and assistant messages unchanged + assert result[0]["content"] == "You are helpful." + assert result[2]["content"] == "Hi there!" + # user message has image replaced + assert result[1]["content"][0]["type"] == "text" + + +# --------------------------------------------------------------------------- +# _strip_image_content_inplace (mutates in place) +# --------------------------------------------------------------------------- + + +class TestStripImageContentInplace: + """Tests for the mutating _strip_image_content_inplace method.""" + + def test_replaces_image_url_with_nondescriptive_placeholder(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this?"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + "_meta": {"path": "/tmp/photo.jpg"}, + }, + ], + } + ] + found = LLMProvider._strip_image_content_inplace(messages) + assert found is True + content = messages[0]["content"] + assert len(content) == 2 + placeholder = content[1]["text"] + assert "/tmp/photo.jpg" not in placeholder + assert "not delivered" in placeholder.lower() or "not describe" in placeholder.lower() + + def test_returns_false_when_no_images(self): + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hello"}]} + ] + assert LLMProvider._strip_image_content_inplace(messages) is False + + def test_mutates_original_messages(self): + original_content = [ + {"type": "text", "text": "see this"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,abc"}, + "_meta": {"path": "/img.png"}, + }, + ] + messages = [{"role": "user", "content": original_content}] + LLMProvider._strip_image_content_inplace(messages) + # The original list was mutated + assert original_content[1]["type"] == "text" + assert "/img.png" not in original_content[1]["text"]