Skip to content

fix(providers): detect image paths with spaces - #9387

Merged
DOsinga merged 8 commits into
aaif-goose:mainfrom
jlmalone:fix/detect-image-path-with-spaces
Jun 16, 2026
Merged

fix(providers): detect image paths with spaces#9387
DOsinga merged 8 commits into
aaif-goose:mainfrom
jlmalone:fix/detect-image-path-with-spaces

Conversation

@jlmalone

@jlmalone jlmalone commented May 22, 2026

Copy link
Copy Markdown
Contributor

Closes #9576

Summary

detect_image_path dropped any image path containing a space — most importantly macOS screenshots like Screenshot 2026-05-04 at 10.32.57 PM.png. Local vision through OpenAI-format providers (LM Studio, Ollama, etc.) then silently received no image, and the model would reply that it couldn't see the file. The function has since moved out of crates/goose/src/providers/utils.rs into the new goose-providers crate (crates/goose-providers/src/images.rs) and was rewritten to split on whitespace there, which reintroduced the same blind spot — this PR re-applies and hardens the fix in its new home.

Change (crates/goose-providers/src/images.rs)

Replace the whitespace split with an extension-anchored scan:

  • For each .png / .jpg / .jpeg occurrence (case-insensitive, allocation-free via find_ascii_ci so the returned byte index stays valid for slicing), walk backward over /-rooted candidates and return one that is an existing image file (is_absolute + is_file + magic-byte is_image_file).
  • Boundary guards so the heuristic doesn't over-match:
    • The extension must terminate the candidate (next char is /, whitespace, a quote, or end of input) — so /tmp/shot.png.backup isn't truncated to /tmp/shot.png.
    • The leading / must follow a whitespace/quote boundary — so a local path isn't carved out of a URL like https://host/some/local/path.png.
    • Quote characters terminate a candidate, so quoted spaced paths ("/tmp/Screen Shot.png") match.
  • Spaced filenames: keep the first referenced path, but allow a longer match anchored at the same start to extend it (an earlier extension can be a prefix of the real, longer filename, e.g. Screen Shot.png edited.jpg).
  • The backward scan is bounded by MAX_PATH_LEN, keeping the function linear even on extension-heavy prompts.

Credit

The main merge, the URL/longer-extension boundary hardening, and the cargo fmt fix were pushed to this branch by @DOsinga — thank you.

Test plan (images.rs unit tests, all passing)

  • test_detect_image_path — existing relative / fake / nonexistent behavior preserved
  • test_detect_image_path_with_spaces — absolute and quoted paths containing spaces
  • test_detect_image_path_ignores_urls_and_longer_extensions — URL suffix and .png.backup not mis-extracted
  • test_detect_image_path_ignores_extension_flood — bounded scan on extension-heavy input
  • test_load_image_file
  • cargo fmt clean; cargo clippy -p goose-providers --all-targets and the images tests pass locally.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4da4bb9aba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines +343 to +347
let Some(ext_idx) = suffix.to_lowercase().find(ext) else {
break;
};
let abs_ext_idx = start_idx + ext_idx;
let end_idx = abs_ext_idx + ext.len();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compute extension index without lowercasing copy

detect_image_path derives ext_idx from suffix.to_lowercase().find(ext) and then reuses that byte index to slice the original text. This is incorrect for Unicode where lowercasing can change byte length (for example İ becomes i + combining dot), so abs_ext_idx/end_idx can point to the wrong byte range and a valid absolute image path is missed. In prompts containing such characters before the path, local image detection silently fails and the image is not attached.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
The previous version called `to_lowercase()` on the search window and
used the resulting byte index against the original `text`. That is
unsound because Unicode lowercasing can change byte length (e.g.
Turkish capital `İ` lowercases to `i` + combining dot above, two
bytes longer), so the computed index can land mid-codepoint or off
the real extension entirely. Any prompt with such a character before
the path would silently miss detection.

Add a small ASCII case-insensitive search over the original byte
buffer. Extensions (`.png`/`.jpg`/`.jpeg`) are pure ASCII, so this is
both correct and faster than allocating a lowercased copy on every
iteration.

Tests cover the Turkish-İ prefix case and an uppercase `.PNG`
extension.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed Codex's P2 in 1f6ff98 — replaced suffix.to_lowercase().find(ext) with an ASCII case-insensitive byte-level search (find_ascii_ci). Extensions are pure ASCII, so we no longer allocate a lowercased copy per iteration and the returned index is guaranteed to map directly into the original text, avoiding the İ → i\u{307} byte-length-shift hazard. Added tests for both the Turkish-İ prefix case and an uppercase .PNG filename.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f6ff98e2a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines +354 to +356
for ext in &extensions {
let mut start_idx = 0;
while let Some(abs_ext_idx) = find_ascii_ci(text, ext, start_idx) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve text order when selecting detected image path

Iterating extensions in fixed order (.png, then .jpg, then .jpeg) changes selection semantics from “first path in the prompt” to “first path for the first matching extension type.” In prompts containing multiple absolute image paths of different types, this can attach the wrong image (for example, an earlier .jpg and a later .png will always choose the later .png), which changes model input and can produce incorrect responses. The scan should prioritize earliest match position in text, not extension precedence.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
The previous version iterated extensions in fixed order (.png, then
.jpg, then .jpeg), so a prompt mentioning an earlier .jpg followed
by a later .png would attach the .png. Restore "first path wins"
semantics by scanning all extensions in lockstep and picking the
match with the earliest byte position.

Adds a test with a .jpg followed by a .png in the same prompt and
asserts the .jpg is selected.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed Codex's P2 in 6ebb907 — the loop now picks the earliest extension match across .png/.jpg/.jpeg at each step instead of exhausting one extension before trying the next, restoring "first path in the prompt wins" semantics. Added a test with an earlier .jpg followed by a later .png that asserts the .jpg is returned.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ebb907bf3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines +367 to +369
let (abs_ext_idx, ext_len) = earliest?;
let end_idx = abs_ext_idx + ext_len;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require boundary after matched image extension

detect_image_path treats any .png/.jpg/.jpeg substring as a candidate end and then searches backward for an existing file, but it never checks that the extension is actually the end of the referenced path. If a prompt contains something like /tmp/photo.png.backup and /tmp/photo.png exists, this will incorrectly attach /tmp/photo.png even though the user did not reference that image. This is a regression from the old ends_with behavior and can cause unintended image data to be sent to providers.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
The backward scan happily accepted `.png` even when it appeared in
the middle of a longer name like `photo.png.backup`. If a sibling
`photo.png` existed on disk, that wrong file would be attached.

Require the extension to terminate the filename: the byte right
after must not be alphanumeric, `_`, or `-`. A literal `.` is
allowed only when followed by a non-alphanumeric (so end-of-sentence
punctuation like `see /tmp/foo.png.` still resolves correctly, while
compound extensions like `.png.backup` are rejected).

Tests cover both the rejection and the trailing-punctuation case.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed Codex's P1 in 27a1118 — the extension match now has to terminate the filename. The byte right after .png/.jpg/.jpeg must not be [a-zA-Z0-9_-], and a literal . is only accepted when followed by a non-alphanumeric. That keeps see /tmp/foo.png. (trailing sentence period) working while rejecting compound names like /tmp/photo.png.backup. Tests cover both behaviors.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 27a1118028

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines +397 to +400
for i in (0..abs_ext_idx).rev() {
let Some(candidate) = text.get(i..end_idx) else {
continue;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject path fragments extracted from URLs

The backward scan currently tries every start byte before the matched extension and accepts the first absolute file that exists, without requiring a delimiter before the candidate. In text like https://example.com/home/alice/photo.png, this can extract /home/alice/photo.png from the URL path and attach a local file if it exists, even though the user only referenced a remote URL. This is a data-leak regression from the old token-based behavior and should be constrained to true standalone path boundaries (e.g., whitespace/quotes/brackets before the path start).

Useful? React with 👍 / 👎.

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines +383 to +388
Some(b) if b.is_ascii_alphanumeric() => true,
Some(b'_' | b'-') => true,
Some(b'.') => bytes
.get(end_idx + 1)
.is_some_and(|c| c.is_ascii_alphanumeric()),
_ => false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat non-ASCII filename continuations as non-terminal

The extension-termination check only treats ASCII alnum/_/- (and some . cases) as continuation characters. If a filename continues with a non-ASCII letter (for example /tmp/foto.pngé), continues_filename is false, so the code can incorrectly truncate to /tmp/foto.png and attach that sibling image if it exists. This is the same class of false-positive the .backup guard addresses, but still reproducible for Unicode filename suffixes.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
…e suffixes

Two more soundness issues in the backward-scan extraction:

1. A URL like `https://example.com/home/alice/photo.png` could leak a
   local file if `/home/alice/photo.png` happened to exist, because
   the scan accepted any absolute substring without checking what sat
   to the left. Add `extends_path_left`: the byte immediately before
   the candidate must not be alphanumeric or any of `/ \ . - _ ~ :`,
   and any non-ASCII byte is conservatively treated as filename
   continuation. The candidate at position 0 is always at a boundary.

2. The right-side termination check only treated ASCII alphanumerics
   (and a few punctuation bytes) as filename continuation, so
   `/tmp/foto.pngé` could be truncated to `/tmp/foto.png` and attach
   a sibling that exists on disk. Extend the rule so any non-ASCII
   byte after the extension — or after a trailing `.` — counts as
   filename continuation.

Tests cover both: a URL containing a real local path, and a Unicode
suffix on top of an existing sibling file.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed both Codex comments in 005be54:

  • P1 (URL extraction): Added extends_path_left. The candidate's start position must sit at a real boundary — the byte immediately before must not be alphanumeric or any of / \\ . - _ ~ :, and non-ASCII is treated as continuation. This blocks pulling /home/alice/photo.png out of https://example.com/home/alice/photo.png.
  • P2 (Unicode suffix): Extended the right-side continues_filename rule to flag any non-ASCII byte after the extension (and after a trailing .). /tmp/foto.pngé no longer truncates to a sibling /tmp/foto.png.

Two tests added covering both cases.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 005be543fd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines +386 to +391
Some(b) if b.is_ascii_alphanumeric() => true,
Some(b'_' | b'-') => true,
Some(b'.') => bytes
.get(end_idx + 1)
.is_some_and(|c| !c.is_ascii() || c.is_ascii_alphanumeric()),
_ => false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat '/' as path continuation after image extension

The extension-termination guard in detect_image_path does not treat / (or \\) as continuation characters, so text like /tmp/photo.png/thumbnail is considered a match for /tmp/photo.png. If that file exists locally, the code will attach it even though the prompt referenced a longer path/route segment, which is a regression from the previous ends_with behavior and can leak unintended local image data. Consider marking slash separators as continuation so only true terminal extensions are accepted.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
`/tmp/photo.png/thumbnail` was being accepted because the extension
guard only flagged alphanumerics and `_`/`-`. With a `/` (or `\`)
right after `.png`, the .png is a directory component in a deeper
path, not a file reference, and we shouldn't truncate to `…/foo.png`
even if that file happens to exist locally.

Adds `/` and `\` to the continues_filename set, with a test.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed Codex's P1 in bc64948 — added / and \\ to the continues_filename set so /tmp/photo.png/thumbnail no longer truncates to /tmp/photo.png. Test added.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc64948f69

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
let bytes = text.as_bytes();
let continues_filename = match bytes.get(end_idx) {
None => false,
Some(b) if !b.is_ascii() => true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow Unicode whitespace after image extensions

The extension terminator check currently treats every non-ASCII byte after .png/.jpg/.jpeg as a filename continuation, so prompts like /tmp/photo.png please describe (ideographic space) or those using non-breaking space are skipped even when the file exists. This is a regression from the previous split_whitespace behavior, which accepted Unicode whitespace separators, and it causes valid local images in multilingual prompts to be silently ignored.

Useful? React with 👍 / 👎.

Comment thread crates/goose/src/providers/utils.rs Outdated
Comment on lines 436 to 438
if !b.is_ascii() {
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept Unicode whitespace as a left path boundary

The left-boundary guard marks any non-ASCII preceding byte as "gluing," which rejects valid absolute paths when they are preceded by Unicode whitespace (for example an ideographic space before /tmp/photo.png). Because text.get(i..end_idx) iterates byte indices, this also evaluates continuation bytes of multibyte separators as non-ASCII glue, preventing detection of otherwise valid image paths in internationalized text.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
Both the post-extension and pre-candidate guards were doing byte-level
classification, which conflates Unicode whitespace (NBSP `\u{00A0}`,
ideographic space `\u{3000}`, etc.) with filename material because
their UTF-8 lead/continuation bytes have the high bit set.

Switch both checks to char-level. The new rule for filename
continuation is:

- alphanumeric (any script) → continuation
- `_ - / \` → continuation
- `.` followed by alphanumeric → continuation
- anything else (whitespace, punctuation, emoji, …) → terminator

`extends_path_left` likewise looks at the preceding *char* via
`char_indices().next_back()`. Unicode whitespace now correctly opens
a left boundary, matching the original `split_whitespace` semantics.

Tests cover ideographic-space prefix and NBSP suffix.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed both Codex P2s in 61e0255 — switched both the post-extension and pre-candidate guards from byte-level to char-level classification.

The new continuation rule: alphanumeric (any script) / _ - / \\ / . followed by alphanumeric → continues a filename; everything else (Unicode whitespace, punctuation, emoji) is a terminator. extends_path_left likewise uses char_indices().next_back() so the previous char (not byte) is classified, restoring split_whitespace semantics for NBSP / ideographic space / etc.

Tests added for ideographic-space prefix and NBSP suffix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61e0255c4e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
.get(end_idx + 1..)
.and_then(|s| s.chars().next())
.is_some_and(|c| c.is_alphanumeric()),
Some(c) => c.is_alphanumeric(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat trailing punctuation as filename continuation

The extension boundary check only treats alphanumerics (plus _, -, /, \, and . followed by alnum) as continuation, so valid filename characters like @, +, ~, ) and others are treated as terminators. In text such as /tmp/photo.png@2x, this code can truncate to /tmp/photo.png and attach that file if it exists, even though the user referenced a different path token; this is a regression from the previous ends_with behavior and can leak unintended local image data.

Useful? React with 👍 / 👎.

@jlmalone

Copy link
Copy Markdown
Contributor Author

Opened #9396 with a longer-term proposal for an atomic-paste span model (the [Image 1] UX you see in Claude Code / Cursor) that would eventually let us retire the heuristic in this PR altogether. Independent of #9387 landing — the heuristic still wants to be safe in the meantime.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
Addresses three soundness issues raised in the latest code review:

P1.1 — Boundary rule was a deny-list, so zero-width spaces, combining
marks, bidi/format controls, emoji, and arbitrary symbols all
silently acted as separators. That let payloads like
`/Users/me/private.png\u{200B}.backup` or
`https://example.com\u{200B}/Users/me/private.png` extract a sibling
local file. Replace the rule with `is_path_separator`, an explicit
allow-list of whitespace plus a small set of punctuation. Everything
else is conservatively treated as filename continuation, so an
attacker cannot use a non-printing character to break out of a
compound name or URL.

P1.2 — Worst case was O(n^2) on multi-megabyte prompts: the outer
loop re-scanned the remaining text for each absent extension, the
inner backward walk could traverse the whole prompt per match, and
`clean_path` allocated a fresh String on every iteration. Replace
the outer with a single forward byte scan (`match_image_extension`),
bound the backward walk to `MAX_PATH_LEN` (4096, > Linux PATH_MAX),
and switch `clean_path` to return `Cow<str>` so the common path
borrows. Added a regression test that runs detect_image_path against
a 1 MB prompt and asserts a sub-second time.

P1.3 — TOCTOU in `load_image_file`: validated via one `File::open`
in `is_image_file`, then read via a second `std::fs::read`. A
swapped symlink between the two opens could substitute an unrelated
file. Read once, validate the magic bytes from the buffer we are
about to base64-encode. Factored out `is_image_bytes(&[u8])` so the
same check works on a path-opened file and on the loaded buffer.

Also adds tests for: zero-width-space / combining mark / bidi
attacks, path at byte 0, file:// URI false negative,
backslash-escaped and quoted paths through `load_image_file`, and a
non-image .png-named file (must reject after the buffer-validation
fix).

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed all three P1s in 1f4110f:

  • P1.1 (boundary controls/marks): Replaced the deny-list with is_path_separator, an explicit allow-list of whitespace + the punctuation that visually separates words. Zero-width spaces, combining marks, bidi/format controls, emoji, and other symbols now glue rather than terminate, so payloads like /Users/me/private.png\u{200B}.backup and https://example.com\u{200B}/Users/me/private.png no longer extract sibling files. Tests added for ZWSP / combining mark / ZWSP-in-URL.
  • P1.2 (O(n²)): Single forward byte scan (match_image_extension) replaces the per-extension re-scan, the backward walk is bounded to MAX_PATH_LEN = 4096 (above Linux PATH_MAX), and clean_path now returns Cow<'_, str> so the common case borrows instead of allocating. Regression test runs detect_image_path against a 1 MB prompt and asserts sub-second.
  • P1.3 (TOCTOU): Extracted is_image_bytes(&[u8]) and reworked load_image_file to read the file exactly once and validate magic bytes from the same buffer it base64-encodes. No more separate check-and-read opens for a symlink to be swapped between. Added a non-image-with-.png-name test that exercises the buffer-validation path.

Tests for P2 partials added: path at byte 0, file:// URI false negative, backslash-escaped and quoted paths through load_image_file. Markdown/HTML embedding behavior is unchanged from upstream (the heuristic still matches src="/abs/path.png" if the file exists); that's an inherited property of the substring approach and is the motivation for #9396. Windows backslash-vs-escaped-space edge cases are out of scope for this PR but tracked.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
Codex flagged that `/tmp/photo.png@2x` could truncate to `/tmp/photo.png`
under the previous deny-list boundary rule. Commit 1f4110f already
replaced that rule with the conservative `is_path_separator` allow-list,
which treats `@`, `~`, `+`, and similar characters as continuation by
default. Add explicit tests so the regression can't sneak back in.

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

FYI — this finding was reviewed against 61e0255, one commit behind. The boundary rewrite in 1f4110f already replaced the deny-list with is_path_separator (whitespace + a small explicit punctuation allow-list); @, ~, +, %, etc. are treated as continuation by default, so /tmp/photo.png@2x no longer truncates. Added explicit regression tests for @2x, ~, and +thumb in 903602e to nail this down.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 903602efeb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose/src/providers/utils.rs Outdated
// this into O(n^2). We don't expect a real absolute path to be
// longer than this.
let scan_floor = abs_ext_idx.saturating_sub(MAX_PATH_LEN);
for j in (scan_floor..abs_ext_idx).rev() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Return full path before shorter absolute suffixes

Iterating j in reverse means the detector tests the shortest absolute suffix first and returns immediately on the first existing file. That can attach the wrong local image when the intended path contains another absolute path as a tail (for example, prompt references /workspace/goose/tmp/photo.png but /tmp/photo.png also exists, so the code returns the latter). This is a correctness and data-exposure regression from the prior whole-token behavior; the scan should prefer the leftmost/full candidate for a matched extension, not the first existing suffix.

Useful? React with 👍 / 👎.

jlmalone added a commit to jlmalone/goose that referenced this pull request May 23, 2026
Previously the inner walk went from `abs_ext_idx - 1` down to
`scan_floor`, which means it tested the *shortest* absolute suffix
first. The `extends_path_left` guard already rejected sub-suffixes
of a longer path (the previous char wasn't a separator), so in
practice the result was the same — but the iteration order made it
non-obvious and Codex flagged it as a latent regression.

Switch to forward iteration. The first valid candidate is now also
the longest, so the intent "prefer the leftmost / full path" is
encoded by the loop itself, not solely by the boundary check. Same
PATH_MAX bound, same boundary rule.

Add a test that builds a nested path and asserts the full path is
returned (not any tail substring that ends in `.png`).

Reported by Codex review on PR aaif-goose#9387.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Addressed in dfc3240. The previous code did iterate backwards (shortest-suffix first), but the extends_path_left guard already rejected any candidate whose left side wasn't a real path-separator — so a wrapped suffix like /tmp/photo.png inside /workspace/x/tmp/photo.png would be skipped because its prev char is alphanumeric. Same final result, but the algorithm's intent was not obvious from the loop direction.

Switched to forward iteration. The first valid candidate is now also the longest, so 'longest valid path wins' is encoded in the loop itself rather than relying on the boundary check to reject sub-suffixes. Added a test that builds a nested wrapper path and asserts the full path round-trips through detect_image_path.

@jlmalone

Copy link
Copy Markdown
Contributor Author

External re-review came back clean — declared ship-safe. Three follow-up notes; one was actionable and just fixed, two tracked separately:

detect_image_path split on whitespace, so an absolute path containing spaces
(a macOS screenshot like /…/Screen Shot 2026.png) was never matched. Anchor on
each image-extension occurrence instead and walk back over '/'-rooted starts,
returning the longest candidate that is an existing image file; the backward
scan is bounded to avoid quadratic work on extension-heavy text. Extension
matching is now case-insensitive. Existing behavior (relative/fake/nonexistent)
is preserved; adds spaces + flood regression tests.

Signed-off-by: Joseph Malone <jlmalone@users.noreply.github.com>
@jlmalone
jlmalone force-pushed the fix/detect-image-path-with-spaces branch from ff26ac5 to 91ddcb4 Compare June 14, 2026 15:36
@jlmalone

Copy link
Copy Markdown
Contributor Author

Rebased onto current main. The original change targeted detect_image_path in crates/goose/src/providers/utils.rs, but main has since extracted that function into the new goose-providers crate (crates/goose-providers/src/images.rs) and rewritten it to split on whitespace — which still drops absolute paths containing spaces (e.g. macOS screenshots). This version re-applies the fix there: it anchors on each image-extension occurrence and walks back over /-rooted starts (bounded to avoid quadratic scanning), returns the longest existing image file, and now matches extensions case-insensitively. Existing behavior (relative/fake/nonexistent paths) is preserved; added spaces + extension-flood regression tests.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 91ddcb43d1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +49 to +52
let Some(end) = EXTENSIONS
.iter()
.any(|ext| word.to_lowercase().ends_with(ext))
{
let path = Path::new(word);
// Check if it's an absolute path and file exists
if path.is_absolute() && path.is_file() {
// Verify it's actually an image file
if is_image_file(path) {
return Some(word);
.filter_map(|ext| find_ascii_ci(text, ext, from).map(|i| i + ext.len()))
.min()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid rescanning absent extensions on every match

When the prompt contains many occurrences of only one supported extension (for example pasted logs with thousands of .png names and no .jpg/.jpeg), this recomputes find_ascii_ci for the absent extensions across the entire remaining suffix on every loop iteration before taking min(). Even though the backward path scan is bounded, this forward search still makes detect_image_path roughly quadratic in extension-heavy text, whereas the previous whitespace scan was linear and provider formatting can stall before sending the request.

Useful? React with 👍 / 👎.

Douwe M Osinga added 2 commits June 15, 2026 10:52
…xtensions

The simplified backward-scan reintroduced two boundary regressions: a path
that is a suffix of a URL (https://host/x.png) could be extracted via the
'://' separator, and a backup file (/tmp/x.png.backup) could be truncated to
the bare image path. Require the extension to terminate the candidate and the
leading '/' to follow a whitespace/quote boundary, and add regression tests.

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
@DOsinga

DOsinga commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this — the core fix (detecting absolute paths with spaces) is a real improvement. I pushed a follow-up commit to your branch (d7912b8) along with a merge of main:

  • Fixed two boundary regressions. The simplified backward-scan that was re-applied after the move to goose-providers dropped the boundary guards from your earlier iterations, so a couple of the cases codex had flagged came back:

    • A path that is a suffix of a URL (e.g. https://host/some/local/path.png) was extracted via the :// separator and would attach a local file if one happened to exist at that path.
    • A backup file like /tmp/shot.png.backup was truncated to /tmp/shot.png.

    The fix requires the extension to terminate the candidate (next char is /, whitespace, or end) and the leading / to follow a whitespace/quote boundary. Added regression tests for both.

  • cargo fmt — the format check was failing in CI; it's clean now.

One thing to tidy up on your side: the PR description is now stale — it still describes a clean_path helper and quote/escape unescaping in load_image_file, but none of that is in the current diff. Could you update the description to match what the code actually does?

cargo clippy -p goose-providers --all-targets and the images tests pass locally. (There's an unrelated pre-existing string_slice clippy error in json.rs that's already on main.)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d7912b812c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose-providers/src/images.rs Outdated
let terminated = text
.get(end..)
.and_then(|rest| rest.chars().next())
.is_none_or(|c| c == '/' || c.is_whitespace());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Accept closing quotes after detected image paths

When a user includes a local path in quotes, e.g. "/tmp/Screen Shot.png", the leading quote is allowed by the start-boundary check below, but the closing quote makes terminated false because only end-of-text, /, or whitespace are accepted after the extension. This causes common quoted paths with spaces to be silently ignored even though the new scanner is meant to handle space-containing paths; include matching quote characters as valid terminators before probing the candidate.

Useful? React with 👍 / 👎.

A quoted path like "/tmp/Screen Shot.png" was silently ignored because the
closing quote was not a valid terminator after the extension, defeating the
whole point of handling space-containing paths. Treat quote characters as
terminators alongside whitespace and '/', and add regression tests for quoted
paths with spaces.

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8aea934b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +82 to +86
let Some(candidate) = text.get(start..end) else {
continue;
};
let path = Path::new(candidate);
if path.is_absolute() && path.is_file() && is_image_file(path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Unescape escaped spaces before probing image paths

When the prompt text itself contains a shell-style escaped space, such as a file dragged or pasted into the interactive terminal as /tmp/Screen\ Shot.png, this new scanner builds the candidate with the backslash still present and probes that literal path. On Unix that is a different filename from /tmp/Screen Shot.png, so the path-with-spaces case still silently fails outside of -t/shell-processed arguments; unescape \ before Path::new/loading or probe the cleaned candidate.

Useful? React with 👍 / 👎.

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bda01f70c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +56 to +57
let terminated =
terminator.is_none_or(|c| c == '/' || c.is_whitespace() || c == '"' || c == '\'');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid returning image prefixes inside spaced filenames

When a filename contains an earlier image extension followed by a space, this treats that space as the end of the path and immediately returns the prefix if it exists. For example, if both /tmp/Screen Shot.png and /tmp/Screen Shot.png edited.jpg exist and the user asks about the latter, the scan stops at the .png prefix and attaches the wrong image before ever considering the later .jpg; the detector should keep looking for a longer existing candidate before accepting a whitespace-terminated extension.

Useful? React with 👍 / 👎.

Douwe M Osinga added 2 commits June 15, 2026 14:47
Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
…dary

A whitespace-terminated extension is ambiguous because the filename may
continue to a later extension (e.g. "Screen Shot.png edited.jpg"). Instead
of returning the first existing prefix, keep scanning and return the longest
existing candidate.

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 066dc83aba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose-providers/src/images.rs Outdated
Comment on lines +78 to +79
if best.is_none_or(|b| candidate.len() > b.len()) {
best = Some(candidate);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the first referenced image path

Fresh evidence: this version now keeps best by candidate.len() across the whole prompt, so when a user mentions multiple existing images in one message, the formatter still attaches only one image but this detector can replace an earlier shorter path with a later longer one. For example, compare /tmp/a.png with /tmp/much-longer.png attaches the later file instead of the first referenced image, which is a regression from the old split_whitespace() scan order and changes the model input the user intended.

Useful? React with 👍 / 👎.

…idates

The longest-wins rule must only apply when a longer match extends the same
start (a spaced filename whose earlier extension is a prefix). Across distinct
paths, preserve the first referenced one to match the prior scan-order
semantics rather than attaching a later, longer path.

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
@jlmalone

Copy link
Copy Markdown
Contributor Author

Thanks @DOsinga — really appreciate you taking it the rest of the way. The two boundary regressions were exactly the cases my rebase onto goose-providers had flattened, and your termination + leading-/ boundary guards are the right call; the longest-existing-candidate ordering is cleaner than what I had.

Done on my side: I've updated the PR description to match the current code — dropped the stale clean_path / load_image_file unescaping section (none of that survived the rebase) and documented the actual extension-anchored scan + boundary guards, with credit to you for the hardening and fmt. Re-ran locally: cargo fmt clean, cargo clippy -p goose-providers --all-targets clean, and all 5 images tests pass. Ready whenever you are.

@DOsinga

DOsinga commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

great. I'm going to merge this

FWIW we're probably moving away from this and let the model handle this direclty using a tool call. more agentic!

@DOsinga
DOsinga added this pull request to the merge queue Jun 16, 2026
Merged via the queue into aaif-goose:main with commit 93285db Jun 16, 2026
0 checks passed
@jlmalone
jlmalone deleted the fix/detect-image-path-with-spaces branch June 17, 2026 13:33
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.

Local vision: image paths containing spaces are silently not detected (detect_image_path)

2 participants