Skip to content

Render markdown images in agent responses#3720

Merged
dgageot merged 1 commit into
docker:mainfrom
rumpl:feat/tui-markdown-images
Jul 17, 2026
Merged

Render markdown images in agent responses#3720
dgageot merged 1 commit into
docker:mainfrom
rumpl:feat/tui-markdown-images

Conversation

@rumpl

@rumpl rumpl commented Jul 17, 2026

Copy link
Copy Markdown
Member
Screenshot 2026-07-17 at 16 10 45

@rumpl
rumpl requested a review from a team as a code owner July 17, 2026 14:13

@Sayt-0 Sayt-0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. [HIGH — security] pkg/tui/image/image.go:235 — LLM-generated file:// or sandbox:// image URLs cause the agent to read arbitrary local files (🟢 97/100)
  2. [MEDIUM — correctness] pkg/tui/components/message/message.go:176 — Failed image fetches are permanently suppressed; no retry path exists (🟢 100/100)
  3. [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)

Comment thread pkg/tui/image/image.go
default:
return Inline{}, false
}
file, err := os.Open(path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 ![img](file:///etc/passwd) (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:

  1. The bytes are still fully read into process memory before the check.
  2. 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 markdown

If 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

Suggested change
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Shift A: 3 < 10Line = 10 + 5 = 15
  2. 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:

Suggested change
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

@dgageot
dgageot merged commit f2f750b into docker:main Jul 17, 2026
11 of 13 checks passed
Sayt-0 added a commit that referenced this pull request Jul 17, 2026
fix(tui): address PR #3720 review feedback on markdown image rendering
pull Bot pushed a commit to TheTechOddBug/cagent that referenced this pull request Jul 17, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants