Module: Rubino::Interaction::ImageInput

Defined in:
lib/rubino/interaction/image_input.rb

Overview

Parses a raw CLI input line and pulls out image attachments so they can be routed to the model’s native vision slot (image_paths) instead of being sent as literal text.

Three input shapes are recognised, mirroring how Claude Code lets a user attach an image from the terminal:

1. `@path/to/pic.png` — the composer's `@` file-picker. When the picked
   file is an image it becomes an attachment; a non-image `@file` is left
   in the text untouched (the model reads it via the `read` tool, as
   before).
2. A dropped / pasted file path — terminals insert an absolute path when
   a file is dragged in, often single/double-quoted or backslash-escaped
   for spaces. An image path (quoted, escaped or bare) is attached.

Only paths that (a) have a recognised image extension AND (b) exist on disk are attached; anything else is preserved verbatim in the returned text so we never silently eat a word that merely looked path-ish.

Every candidate attachment is then gated through the SAME secure-by-default attachment layer the server/run path uses (Attachments::Classify + Policy: lstat/realpath safety pipeline, max_file_bytes cap, magic-byte kind check) — the CLI used to bypass it entirely, shipping oversize/spoofed files to the provider and burning the retry budget on the permanent error (#98). A rejected candidate is consumed from the text and reported in Result#rejected so the caller can surface a clean one-line error.

Returns a Result with the cleaned text (image tokens removed, whitespace collapsed), the de-duplicated, expanded absolute image paths in order, and any policy rejections as { path:, reason: } hashes.

Defined Under Namespace

Classes: Result

Constant Summary collapse

AT_TOKEN =

An ‘@token`: `@` followed by a run of non-space chars. Quoting inside an `@` token isn’t a terminal convention, so we keep it simple.

/(?<![^\s])@(\S+)/
QUOTED_PATH =

A quoted path: ‘…’ or “…” (drag-drop on terminals that quote).

/(?<![^\s])(?:'([^']+)'|"([^"]+)")/
BARE_PATH =

A bare / backslash-escaped path token: a leading /, ./, ../ or ~/ then a run of non-space chars, allowing ‘\ ` escaped spaces (drag-drop default on iTerm/Terminal.app). Anchored at a word boundary so it doesn’t bite into the middle of a URL or sentence.

%r{(?<![^\s])((?:~|\.{0,2})/(?:\\.|\S)+)}

Class Method Summary collapse

Class Method Details

.attachment_error(path) ⇒ Object

Gates one candidate image through the universal attachment layer —Attachments::Classify (lstat/realpath safety pipeline, max_file_bytes cap, magic-byte classification) + Policy.allow_kind? — the SAME checks the server/run path applies (#98). Returns a one-line human reason when the file must NOT be attached, nil when it is safe to send.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/rubino/interaction/image_input.rb', line 103

def attachment_error(path)
  cls = Attachments::Classify.call(path)
  unless cls.safe
    if cls.reason.to_s.start_with?("exceeds max_file_bytes")
      return "exceeds the #{Attachments::Policy.max_file_bytes / 1_048_576} MB attachment limit"
    end

    return cls.reason
  end
  return "not a valid image (content is #{cls.mime})" unless cls.kind == :image
  return "image attachments are disabled by policy (allow_kinds)" unless Attachments::Policy.allow_kind?(:image)

  nil
end

.capture_if_image(token, original, paths, rejected) ⇒ Object

If token resolves to an existing image file, record its absolute path and drop it from the text (returns “”); otherwise leave the original match (original) untouched. original is captured by the caller before any path work, because #expand runs its own gsub and would clobber Regexp.last_match here. A candidate that LOOKS like an image but fails the attachment policy is consumed too — never shipped, never left as a path the model would chase with tools — and recorded in rejected.



86
87
88
89
90
91
92
93
94
95
96
# File 'lib/rubino/interaction/image_input.rb', line 86

def capture_if_image(token, original, paths, rejected)
  path = expand(token)
  return original unless LLM::ContentBuilder.image_file?(path) && File.file?(path)

  if (reason = attachment_error(path))
    rejected << { path: path, reason: reason }
  else
    paths << path unless paths.include?(path)
  end
  ""
end

.expand(token) ⇒ Object

Normalises a raw token into an absolute filesystem path: strips backslash escapes (‘\ ` → ` `) and expands `~`/relative paths.



120
121
122
123
124
# File 'lib/rubino/interaction/image_input.rb', line 120

def expand(token)
  File.expand_path(token.to_s.gsub(/\\(.)/, '\1'))
rescue ArgumentError
  token.to_s
end

.parse(input, existing: []) ⇒ Object

Extracts image attachments from input. existing lets a caller carry forward images already attached to the pending turn (e.g. a clipboard paste) so a follow-up line’s parse adds to them rather than replacing.



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rubino/interaction/image_input.rb', line 60

def parse(input, existing: [])
  text = input.to_s
  paths = []
  rejected = []

  text = text.gsub(AT_TOKEN) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) }
  text = text.gsub(QUOTED_PATH) do
    token = Regexp.last_match(1) || Regexp.last_match(2)
    capture_if_image(token, Regexp.last_match(0), paths, rejected)
  end
  text = text.gsub(BARE_PATH) { capture_if_image(Regexp.last_match(1), Regexp.last_match(0), paths, rejected) }

  Result.new(
    text: text.gsub(/[ \t]{2,}/, " ").strip,
    image_paths: (Array(existing) + paths).uniq,
    rejected: rejected.uniq
  )
end