Class: Rubino::Tools::ReadAttachmentTool

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/tools/read_attachment_tool.rb

Overview

Gated, on-demand attachment reader (#6). Instead of every attachment's bytes being inlined into the prompt by default, the model calls this tool only when it actually needs a document's content -- the single biggest reduction in prompt-injection surface from the attachment work.

Pipeline (reuses the audited primitives; invents nothing new):

1. Attachments::Classify.call (fail-closed: lstat -> realpath-confine to
 the workspace -> size cap -> magic-bytes-wins MIME). Only a safe,
 policy-allowed document/text proceeds.
2. Documents.to_markdown -- in-process conversion (pdf/docx/xlsx/pptx/
 html/csv/json/xml/plain). Returns nil when no in-process converter can
 handle the format (e.g. the optional gem isn't installed).
3. On nil: return the existing actionable shell-extraction hint
 (Preamble.document_shell_hint) -- NEVER raise, so a missing optional
 gem can't break a turn.
4. Oversized Markdown is SPILLED to a persistent file and a framed
 pointer is returned (read/grep it on demand) rather than dumped into
 context -- the model pages it like any other large file.
5. Inline-sized Markdown is wrapped in Preamble's nonce-framed untrusted
 envelope (converted document = untrusted user data).

Constant Summary collapse

MAX_SPILL_BYTES =

Refuse to spill a CONVERTED document larger than this (≈20MB, matching Gemini's cap). Attachments::Classify already caps the SOURCE size; this guards the post-conversion Markdown, which a converter can balloon.

20_000_000

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk, #stream_kind

Instance Method Summary collapse

Methods inherited from Base

#cancellation_requested?, #display_name, #emit_chunk, #mcp?, #risky?, #to_tool_definition, workspace_root, workspace_roots

Instance Method Details

#call(arguments) ⇒ Object



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/rubino/tools/read_attachment_tool.rb', line 69

def call(arguments)
  file_path = (arguments["file_path"] || arguments[:file_path]).to_s
  return "Error: file_path is required" if file_path.empty?

  # Classify runs the fail-closed safety pipeline (lstat rejects symlink/
  # FIFO/device, size cap, magic-bytes-wins MIME). We then confine to the
  # workspace via Base#within_workspace?, which checks ALL allowed roots
  # (primary + every --add-dir) and resolves symlinks -- a single
  # confine_dir can't express the multi-root sandbox the agent uses.
  cls = Attachments::Classify.call(file_path)
  unless cls.safe
    return "Error: cannot read #{file_path}: #{cls.reason}. " \
           "Attachments must be regular files inside the workspace, under the size cap."
  end
  return workspace_violation_message(file_path) unless within_workspace?(cls.path)
  unless Attachments::Policy.allow_kind?(cls.kind)
    return "Error: #{file_path} is a #{cls.kind} (#{cls.mime}); read_attachment only " \
           "reads documents and text. Inspect other kinds via the shell."
  end

  # Thread the cancel_token so a runaway/bomb conversion is interruptible
  # mid-flight and bounded by the converter's wall-clock/element caps.
  markdown = Rubino::Documents.to_markdown(cls.path, mime: cls.mime, cancel_token: @cancel_token)
  # No in-process converter (unknown format / optional gem absent): degrade
  # with the actionable shell-extraction hint, exactly like the preamble.
  # NEVER raise -- a missing gem must not break the turn.
  return Attachments::Preamble.document_shell_hint(cls) if markdown.nil?

  # Redact credential values from the converted content before it enters
  # context -- parity with the read/grep/shell seams (Security::Redactor).
  # A document is untrusted DATA, not source, so use the FULL pattern set
  # (code_file:false, like the shell seam): `API_KEY=sk-...` assignments in
  # a csv/spreadsheet are real secrets and must be masked. Honors the
  # `security.redact_secrets` opt-out internally (default ON). This single
  # seam covers both return paths (frame + spill) that emit content.
  markdown = Security::Redactor.redact_sensitive_text(markdown, code_file: false)

  if oversized?(markdown)
    spill_oversized(cls, markdown)
  else
    frame(cls, markdown)
  end
rescue Rubino::Interrupted
  raise
rescue StandardError => e
  # A real failure AFTER the fail-closed classification already passed
  # (conversion/redaction/spill blew up). The turn still survives, but
  # we surface a genuine error with the cause instead of FABRICATING a
  # `Classification(safe: true)` just to reach the shell-hint — that fake
  # masked to_markdown/redaction bugs and could misreport an unsafe path
  # as safe. Log the actual message so the bug is observable.
  Rubino.logger&.warn(event: "read_attachment.failed", path: file_path,
                      error: "#{e.class}: #{e.message}")
  "Error: could not read #{file_path}: #{e.message}. " \
    "Extract its text with a shell tool instead, e.g. `markitdown #{file_path}` " \
    "(fallback `pdftotext #{file_path} -`, or `textutil -convert txt #{file_path}` on macOS), " \
    "then read the output."
end

#config_keyObject



37
38
39
# File 'lib/rubino/tools/read_attachment_tool.rb', line 37

def config_key
  "read_attachment"
end

#descriptionObject



41
42
43
44
45
46
47
48
49
50
# File 'lib/rubino/tools/read_attachment_tool.rb', line 41

def description
  "Read an attached document on demand, converting it to Markdown IN-PROCESS " \
    "(PDF, DOCX, XLSX, PPTX, HTML, CSV, JSON, XML, plain/code) and returning the " \
    "text framed as untrusted user data. Prefer this over shelling out to " \
    "`markitdown`/`pdftotext`. Pass the path the attachment was staged at. A " \
    "document too large to inline is written to a file you then page with " \
    "`read` (offset/limit) or `grep`, instead of flooding this conversation. " \
    "If the format has no in-process converter, you get an actionable " \
    "shell-extraction hint instead."
end

#input_schemaObject



52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/rubino/tools/read_attachment_tool.rb', line 52

def input_schema
  {
    type: "object",
    properties: {
      file_path: {
        type: "string",
        description: "Path to the attachment to read (absolute or workspace-relative)."
      }
    },
    required: %w[file_path]
  }
end

#nameObject



33
34
35
# File 'lib/rubino/tools/read_attachment_tool.rb', line 33

def name
  "read_attachment"
end

#risk_levelObject



65
66
67
# File 'lib/rubino/tools/read_attachment_tool.rb', line 65

def risk_level
  :low
end