Render markdown images in agent responses#3720
Conversation
rumpl
commented
Jul 17, 2026
Signed-off-by: Djordje Lukic <[email protected]>
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🔴 CRITICAL
This PR adds markdown image rendering to the TUI. Three bugs were found in the new code — one security vulnerability and two correctness issues.
Findings summary
- [HIGH — security]
pkg/tui/image/image.go:235— LLM-generatedfile://orsandbox://image URLs cause the agent to read arbitrary local files (🟢 97/100) - [MEDIUM — correctness]
pkg/tui/components/message/message.go:176— Failed image fetches are permanently suppressed; no retry path exists (🟢 100/100) - [MEDIUM — correctness]
pkg/tui/components/message/message.go:536— Code-block line offsets are miscalculated when an image placeholder appears after a code block (🟢 100/100)
| default: | ||
| return Inline{}, false | ||
| } | ||
| file, err := os.Open(path) |
There was a problem hiding this comment.
[HIGH — security] Arbitrary local file read via file:// or sandbox:// image URL in LLM-generated markdown
LoadMarkdownReference accepts file:// and sandbox:// URIs in its else branch and opens the resolved path with os.Open unconditionally. Because this function is called with URLs extracted from assistant message content — i.e., text produced by the LLM — an adversarially crafted or prompt-injected response containing  (or any other sensitive path) will cause the agent to read up to 20 MiB of that file into memory.
FromBytes will reject non-image MIME types, so the data won't be rendered, but:
- The bytes are still fully read into process memory before the check.
- Any error-path logging, telemetry, or side channel (e.g., timing, memory pressure) that leaks byte counts or partial content could enable exfiltration.
This is a confused-deputy attack: the agent reads local files on behalf of LLM-supplied URLs. The sandbox:// alias doesn't restrict the path at all — it's treated identically to file:// with the raw parsed.Path.
Suggested fix: reject file:// and sandbox:// URIs in the markdown image loader. Since these schemes are only appropriate for tool-result images (loaded via a separate, trusted path), they should never be accepted when the source is assistant text content:
// In LoadMarkdownReference, replace the sandbox/file case with an explicit rejection:
case parsed.Scheme == "file", parsed.Scheme == "sandbox":
return Inline{}, false // never allow local paths from untrusted markdownIf local-path support is intentional for a specific future use-case, add an allowlisted directory check (e.g., strings.HasPrefix(resolved, allowedDir)).
| Confidence | Score |
|---|---|
| 🟢 strong | 97/100 |
| if _, loaded := mv.markdownImages[ref.Source]; loaded || mv.loadingImages[ref.Source] { | ||
| continue | ||
| } | ||
| mv.loadingImages[ref.Source] = true |
There was a problem hiding this comment.
[MEDIUM — correctness] Failed image URLs are permanently blocked from retrying
loadMarkdownImages marks each URL as in-flight by setting mv.loadingImages[ref.Source] = true before the fetch begins. When markdownImagesLoadedMsg is processed in Update, only the successfully fetched images are copied into mv.markdownImages; failed URLs are never removed from loadingImages.
The guard check mv.loadingImages[ref.Source] then permanently suppresses any retry for those URLs on every subsequent SetMessage call. A transient network error, DNS hiccup, or server-side 5xx during streaming will silently leave the image slot blank for the entire session with no recovery path.
If "try once" semantics are intentional (e.g., to avoid hammering a broken server), that should be documented. Otherwise, remove failed URLs from loadingImages when processing markdownImagesLoadedMsg:
| mv.loadingImages[ref.Source] = true | |
| if loaded, ok := msg.(markdownImagesLoadedMsg); ok && loaded.target == mv { | |
| for src := range mv.loadingImages { | |
| if _, success := loaded.images[src]; !success { | |
| delete(mv.loadingImages, src) | |
| } | |
| } | |
| if len(loaded.images) > 0 { |
| Confidence | Score |
|---|---|
| 🟢 strong | 100/100 |
| } | ||
| for i := range codeBlocks { | ||
| for _, shift := range shifts { | ||
| if shift.line < codeBlocks[i].Line { |
There was a problem hiding this comment.
[MEDIUM — correctness] Code-block line-shift miscalculation when an image placeholder appears after the code block's original position
In replaceMarkdownImagePlaceholders, code-block line numbers are adjusted by iterating over all image-placeholder shifts. The comparison is:
if shift.line < codeBlocks[i].Line {
codeBlocks[i].Line += shift.delta
}The problem is that codeBlocks[i].Line is mutated in-place during the inner loop, so subsequent comparisons use the already-shifted value rather than the original pre-replacement line number.
Concrete failure scenario:
- Code block at original rendered line 10
- Image placeholder A at original line 3 (expands by 5 lines → delta = 5)
- Image placeholder B at original line 12 (expands by 2 lines → delta = 2)
Expected: B is after the code block (original line 12 > original line 10), so B should NOT shift the code block. Correct adjusted line = 10 + 5 = 15.
Actual with the current code:
- Shift A:
3 < 10→Line = 10 + 5 = 15✓ - Shift B:
12 < 15(true! compares against mutated value) →Line = 15 + 2 = 17✗
The code block ends up shifted by an extra 2 lines, causing click-to-copy to target the wrong content.
Fix: compare against the original line number by saving it before the inner loop:
| if shift.line < codeBlocks[i].Line { | |
| for i := range codeBlocks { | |
| orig := codeBlocks[i].Line | |
| for _, shift := range shifts { | |
| if shift.line < orig { | |
| codeBlocks[i].Line += shift.delta | |
| } | |
| } | |
| } |
| Confidence | Score |
|---|---|
| 🟢 strong | 100/100 |
fix(tui): address PR #3720 review feedback on markdown image rendering
…ndering Reject file:// and sandbox:// URIs in LoadMarkdownReference to prevent prompt-injected markdown from reading local files. Clear failed image sources from loadingImages so a later SetMessage can retry them. Fix code-block line-shift comparison to use the pre-replacement line number. Assisted-By: Claude