rubino-agent 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +137 -1
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +28 -1
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/troubleshooting.md +1 -1
  15. data/exe/rubino +16 -2
  16. data/install.sh +715 -54
  17. data/lib/rubino/active_agent.rb +73 -0
  18. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  19. data/lib/rubino/agent/agent_registry.rb +5 -2
  20. data/lib/rubino/agent/definition.rb +1 -9
  21. data/lib/rubino/agent/fallback_chain.rb +0 -6
  22. data/lib/rubino/agent/iteration_budget.rb +109 -3
  23. data/lib/rubino/agent/loop.rb +476 -20
  24. data/lib/rubino/agent/model_call_runner.rb +81 -3
  25. data/lib/rubino/agent/prompts/build.txt +22 -5
  26. data/lib/rubino/agent/response_validator.rb +8 -0
  27. data/lib/rubino/agent/runner.rb +133 -8
  28. data/lib/rubino/agent/tool_executor.rb +166 -14
  29. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  30. data/lib/rubino/api/server.rb +19 -0
  31. data/lib/rubino/boot/config_guard.rb +71 -0
  32. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  33. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  34. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  35. data/lib/rubino/cli/chat_command.rb +1189 -50
  36. data/lib/rubino/cli/commands.rb +281 -1
  37. data/lib/rubino/cli/config_command.rb +68 -8
  38. data/lib/rubino/cli/doctor_command.rb +204 -12
  39. data/lib/rubino/cli/jobs_command.rb +12 -0
  40. data/lib/rubino/cli/memory_command.rb +53 -20
  41. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  42. data/lib/rubino/cli/session_command.rb +172 -18
  43. data/lib/rubino/cli/setup_command.rb +131 -8
  44. data/lib/rubino/cli/skills_command.rb +67 -20
  45. data/lib/rubino/cli/trust_gate.rb +16 -7
  46. data/lib/rubino/commands/built_ins.rb +2 -0
  47. data/lib/rubino/commands/command.rb +12 -2
  48. data/lib/rubino/commands/executor.rb +149 -12
  49. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  50. data/lib/rubino/commands/handlers/agents.rb +133 -38
  51. data/lib/rubino/commands/handlers/config.rb +4 -1
  52. data/lib/rubino/commands/handlers/help.rb +113 -14
  53. data/lib/rubino/commands/handlers/memory.rb +15 -5
  54. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  55. data/lib/rubino/commands/handlers/status.rb +9 -4
  56. data/lib/rubino/commands/loader.rb +12 -0
  57. data/lib/rubino/config/configuration.rb +86 -24
  58. data/lib/rubino/config/defaults.rb +140 -33
  59. data/lib/rubino/config/loader.rb +62 -12
  60. data/lib/rubino/config/validator.rb +341 -0
  61. data/lib/rubino/config/writer.rb +123 -31
  62. data/lib/rubino/context/compressor.rb +184 -22
  63. data/lib/rubino/context/message_boundary.rb +27 -1
  64. data/lib/rubino/context/project_languages.rb +90 -0
  65. data/lib/rubino/context/prompt_assembler.rb +104 -21
  66. data/lib/rubino/context/summary_builder.rb +45 -4
  67. data/lib/rubino/context/token_budget.rb +36 -11
  68. data/lib/rubino/context/token_estimate.rb +45 -0
  69. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  70. data/lib/rubino/database/connection.rb +154 -3
  71. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  72. data/lib/rubino/database/migrator.rb +98 -5
  73. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  74. data/lib/rubino/documents/converters/csv.rb +4 -3
  75. data/lib/rubino/documents/converters/docx.rb +29 -5
  76. data/lib/rubino/documents/converters/html.rb +5 -1
  77. data/lib/rubino/documents/converters/json.rb +2 -1
  78. data/lib/rubino/documents/converters/pdf.rb +11 -2
  79. data/lib/rubino/documents/converters/plain.rb +2 -1
  80. data/lib/rubino/documents/converters/pptx.rb +11 -2
  81. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  82. data/lib/rubino/documents/converters/xml.rb +2 -1
  83. data/lib/rubino/documents/limits.rb +210 -0
  84. data/lib/rubino/documents.rb +10 -3
  85. data/lib/rubino/errors.rb +36 -5
  86. data/lib/rubino/interaction/cancel_token.rb +19 -3
  87. data/lib/rubino/interaction/events.rb +13 -0
  88. data/lib/rubino/interaction/lifecycle.rb +99 -13
  89. data/lib/rubino/interaction/polishing.rb +176 -0
  90. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  91. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  92. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  93. data/lib/rubino/jobs/queue.rb +63 -8
  94. data/lib/rubino/jobs/runner.rb +24 -6
  95. data/lib/rubino/jobs/worker.rb +0 -4
  96. data/lib/rubino/llm/adapter_response.rb +47 -4
  97. data/lib/rubino/llm/credential_check.rb +15 -16
  98. data/lib/rubino/llm/error_classifier.rb +89 -1
  99. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  100. data/lib/rubino/llm/request.rb +30 -3
  101. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  102. data/lib/rubino/llm/tool_bridge.rb +113 -9
  103. data/lib/rubino/mcp/manager.rb +18 -1
  104. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  105. data/lib/rubino/memory/aux_retry.rb +107 -0
  106. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  107. data/lib/rubino/memory/backends.rb +23 -7
  108. data/lib/rubino/memory/salience_gate.rb +103 -0
  109. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  110. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  111. data/lib/rubino/memory/store.rb +33 -5
  112. data/lib/rubino/memory/threat_scanner.rb +52 -0
  113. data/lib/rubino/output/cost.rb +52 -0
  114. data/lib/rubino/output/headless_block_latch.rb +53 -0
  115. data/lib/rubino/output/result_serializer.rb +222 -0
  116. data/lib/rubino/output/turn_recorder.rb +77 -0
  117. data/lib/rubino/security/approval_policy.rb +227 -32
  118. data/lib/rubino/security/command_allowlist.rb +79 -4
  119. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  120. data/lib/rubino/security/hardline_guard.rb +189 -16
  121. data/lib/rubino/security/pattern_matcher.rb +28 -5
  122. data/lib/rubino/security/prefix_deriver.rb +25 -6
  123. data/lib/rubino/security/readonly_commands.rb +145 -5
  124. data/lib/rubino/security/secret_path.rb +134 -0
  125. data/lib/rubino/security/url_safety.rb +255 -0
  126. data/lib/rubino/session/repository.rb +212 -11
  127. data/lib/rubino/session/store.rb +139 -14
  128. data/lib/rubino/skills/installer.rb +116 -32
  129. data/lib/rubino/skills/prompt_index.rb +2 -2
  130. data/lib/rubino/skills/registry.rb +42 -1
  131. data/lib/rubino/skills/skill.rb +63 -2
  132. data/lib/rubino/skills/skill_tool.rb +16 -5
  133. data/lib/rubino/tools/background_tasks.rb +122 -9
  134. data/lib/rubino/tools/base.rb +204 -3
  135. data/lib/rubino/tools/edit_tool.rb +73 -18
  136. data/lib/rubino/tools/glob_tool.rb +48 -9
  137. data/lib/rubino/tools/grep_tool.rb +103 -9
  138. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  139. data/lib/rubino/tools/patch_tool.rb +5 -0
  140. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  141. data/lib/rubino/tools/read_tool.rb +33 -15
  142. data/lib/rubino/tools/read_tracker.rb +153 -35
  143. data/lib/rubino/tools/registry.rb +113 -12
  144. data/lib/rubino/tools/result.rb +9 -1
  145. data/lib/rubino/tools/ruby_tool.rb +0 -0
  146. data/lib/rubino/tools/shell_registry.rb +70 -0
  147. data/lib/rubino/tools/shell_tool.rb +40 -1
  148. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  149. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  150. data/lib/rubino/tools/task_tool.rb +36 -8
  151. data/lib/rubino/tools/vision_tool.rb +5 -0
  152. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  153. data/lib/rubino/tools/websearch_tool.rb +92 -30
  154. data/lib/rubino/tools/write_tool.rb +23 -4
  155. data/lib/rubino/ui/api.rb +10 -1
  156. data/lib/rubino/ui/base.rb +11 -0
  157. data/lib/rubino/ui/bottom_composer.rb +382 -74
  158. data/lib/rubino/ui/cli.rb +515 -83
  159. data/lib/rubino/ui/completion_menu.rb +11 -7
  160. data/lib/rubino/ui/headless_trace.rb +63 -0
  161. data/lib/rubino/ui/live_region.rb +70 -7
  162. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  163. data/lib/rubino/ui/notifier.rb +0 -2
  164. data/lib/rubino/ui/null.rb +52 -5
  165. data/lib/rubino/ui/paste_store.rb +16 -2
  166. data/lib/rubino/ui/queued_indicators.rb +6 -1
  167. data/lib/rubino/ui/status_bar.rb +61 -7
  168. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  169. data/lib/rubino/ui/subagent_view.rb +15 -1
  170. data/lib/rubino/ui/tool_label.rb +52 -0
  171. data/lib/rubino/update_check.rb +39 -4
  172. data/lib/rubino/util/atomic_file.rb +117 -0
  173. data/lib/rubino/util/ignore_rules.rb +120 -0
  174. data/lib/rubino/util/output.rb +229 -12
  175. data/lib/rubino/util/secrets_mask.rb +70 -7
  176. data/lib/rubino/util/spill_store.rb +153 -0
  177. data/lib/rubino/version.rb +1 -1
  178. data/lib/rubino/workspace.rb +9 -1
  179. data/lib/rubino.rb +191 -7
  180. data/rubino-agent.gemspec +1 -0
  181. data/skills/ruby-expert/SKILL.md +1 -0
  182. metadata +41 -12
  183. data/lib/rubino/agent/router.rb +0 -65
  184. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  185. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  186. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  187. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  188. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  189. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  190. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  191. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  192. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -70,7 +70,19 @@ module Rubino
70
70
  before = (ctx || arguments["before"] || arguments[:before] || 0).to_i.clamp(0, 50)
71
71
  after = (ctx || arguments["after"] || arguments[:after] || 0).to_i.clamp(0, 50)
72
72
 
73
- expanded_path = File.expand_path(path)
73
+ expanded_path = expand_workspace_path(path)
74
+ # Search is BROAD (#406): grep resolves any NON-secret path like
75
+ # Hermes/Claude/Codex. A grep whose `path` is a SECRET file directly
76
+ # (#446) is gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask),
77
+ # exactly like read — so it is NOT refused here; an approved grep of a
78
+ # secret file proceeds, a denied/headless one never reaches #call.
79
+ #
80
+ # F2: a DIRECTORY grep with `include: "*.env"` is NOT a secret target —
81
+ # the gate above can't see it — but rg's --glob OVERRIDES the default
82
+ # hidden-exclusion and would LEAK the matched .env lines. We therefore
83
+ # post-filter the RESULTS (see #filter_secret_hits): any result line that
84
+ # points at a secret file is stripped, so secrets never escape via an
85
+ # include-glob regardless of approval.
74
86
  return "Error: Path not found: #{path}" unless File.exist?(expanded_path)
75
87
 
76
88
  if ripgrep_available?
@@ -86,6 +98,28 @@ module Rubino
86
98
  system("which rg > /dev/null 2>&1")
87
99
  end
88
100
 
101
+ # True when an rg output line (`<file>:<lineno>:…`, a `<file>:<lineno>-…`
102
+ # context line, or a bare `--` separator) points at a secret/credential
103
+ # file — used to strip it from the result set so an include-glob over a
104
+ # directory can't leak a secret (F2). rg prints the file path verbatim
105
+ # from the search root we gave it; when the root is a single FILE rg omits
106
+ # the path prefix, but that case is the directly-targeted (approved) grep,
107
+ # so we resolve a bare line against `search_root` and let it fall through
108
+ # as non-secret. The `--` separator carries no path and is kept.
109
+ def secret_result_line?(line, search_root)
110
+ return false if line.nil? || line.start_with?("--")
111
+
112
+ # Split off the leading "<file>:<lineno>" — rg uses ':' for matches and
113
+ # ':'/'-' for context, always after the line number. Take everything up
114
+ # to the LAST ':' or '-' that precedes a digit run + delimiter.
115
+ m = line.match(/\A(.*?):\d+[:-]/)
116
+ return false unless m
117
+
118
+ file = m[1]
119
+ file = File.expand_path(file, search_root) unless file.start_with?(File::SEPARATOR)
120
+ !secret_path_category(file).nil?
121
+ end
122
+
89
123
  def search_with_ripgrep(pattern, path, include_pattern, max_results, before, after)
90
124
  # Build argv array and use Open3 to avoid shell injection — pattern
91
125
  # and path are passed as separate arguments, never interpolated into a
@@ -104,29 +138,75 @@ module Rubino
104
138
  argv += ["-A", after.to_s] if after.positive?
105
139
  argv += [pattern, path]
106
140
 
107
- output = IO.popen(argv, err: %i[child out], &:read)
141
+ # STREAM rg's output line-by-line and STOP after max_results (#375a).
142
+ # `IO.popen(argv).read` buffered the ENTIRE rg output — a pattern that
143
+ # matches a huge file produced +100MB in memory just to `.first(50)` it.
144
+ # Read until we have max_results+1 lines (the +1 detects "there are
145
+ # more"), then close the pipe (SIGPIPE stops rg) so neither memory nor
146
+ # CPU scale with the match count.
147
+ # F2: filter secret hits ONLY for a DIRECTORY search (an include-glob
148
+ # like `*.env` can pull a credential file in). A grep whose path is the
149
+ # secret FILE itself was already approved by the upstream gate, so its
150
+ # own lines must be returned, not stripped.
151
+ filter_secrets = File.directory?(path)
152
+ lines = []
153
+ more_exist = false
154
+ IO.popen(argv, err: %i[child out]) do |io|
155
+ io.each_line do |line|
156
+ # Drop a hit that points at a secret file BEFORE it counts toward the
157
+ # cap, so a result set of only-secrets doesn't crowd out the cap with
158
+ # content we'll never return.
159
+ next if filter_secrets && secret_result_line?(line, path)
160
+
161
+ if lines.size >= max_results
162
+ more_exist = true
163
+ break
164
+ end
165
+ lines << line
166
+ end
167
+ io.close # close early → rg gets SIGPIPE and stops scanning
168
+ end
108
169
  status = $?.exitstatus
170
+ # When WE deliberately close the pipe early after hitting the cap
171
+ # (#391/regression #375), rg is killed mid-scan and exits non-zero —
172
+ # and on some platforms the broken-pipe exit is reported as 1, the SAME
173
+ # code rg uses for a genuine "no matches". The old `status != 1` guard
174
+ # therefore EXCLUDED that case and fell through to the `status == 1`
175
+ # branch, dropping the 50 matches we already collected and reporting
176
+ # "No matches". Whenever we collected matches AND closed early (more_exist),
177
+ # it is unambiguously a success regardless of rg's exit code; a real
178
+ # "no matches" is 0 collected lines and we never closed early, so it
179
+ # still reaches the status==1 branch and reports correctly.
180
+ status = 0 if lines.any? && (more_exist || status != 1)
109
181
 
110
182
  if status == 0
111
- all_lines = output.lines
112
- lines = all_lines.first(max_results)
113
- more = all_lines.size - lines.size
183
+ # We can't cheaply know the exact remaining count once we stop early,
184
+ # so report "more" without an exact number when the cap was hit.
185
+ more = more_exist
114
186
  header = "#{lines.size} match(es) shown" \
115
- "#{" (#{more} more — raise max_results or narrow the pattern)" if more.positive?}"
187
+ "#{" (more — raise max_results or narrow the pattern)" if more}"
116
188
  full = "#{header}:\n\n#{lines.join}"
117
189
  { output: full,
118
- metrics: "#{lines.size} match#{"es" if lines.size != 1}#{"+" if more.positive?}",
190
+ metrics: "#{lines.size} match#{"es" if lines.size != 1}#{"+" if more}",
119
191
  body: Util::Output.preview(full),
120
192
  body_kind: :plain }
121
193
  elsif status == 1
122
194
  "No matches found for pattern: #{pattern}"
123
195
  else
124
- "Error executing search: #{output}"
196
+ "Error executing search: #{lines.join}"
125
197
  end
126
198
  end
127
199
 
128
200
  def search_with_ruby(pattern, path, include_pattern, max_results, before, after)
129
- regex = Regexp.new(pattern)
201
+ # The Ruby fallback is the LIVE path whenever rg isn't on PATH. A bad
202
+ # pattern the model emits (e.g. an unclosed paren) would otherwise
203
+ # raise RegexpError and hand the model a raw exception; return a clean,
204
+ # actionable tool error instead.
205
+ begin
206
+ regex = Regexp.new(pattern)
207
+ rescue RegexpError => e
208
+ return "Error: invalid regex pattern: #{e.message}"
209
+ end
130
210
  results = []
131
211
 
132
212
  # ripgrep accepts a single FILE as well as a directory; mirror that
@@ -134,8 +214,22 @@ module Rubino
134
214
  # `path` is a file we search it directly (include_pattern is moot).
135
215
  files = File.file?(path) ? [path] : Dir.glob(File.join(path, "**", include_pattern || "*"))
136
216
 
217
+ # Honor .gitignore the SAME way the rg path does (#375b): without this
218
+ # the fallback returned a different, larger set (build artifacts,
219
+ # node_modules, ignored secrets) than rg — non-deterministic on whether
220
+ # rg is installed. A single FILE path the model targeted directly is
221
+ # always searched (mirrors rg searching an explicit file argument).
222
+ ignore = Util::IgnoreRules.new
223
+ searching_file = File.file?(path)
224
+
137
225
  files.each do |file|
138
226
  next unless File.file?(file)
227
+ next if !searching_file && ignore.ignored?(file, path)
228
+ # F2: in a DIRECTORY search, never read a secret file's lines into
229
+ # results (an include-glob like `*.env` would otherwise leak it). A
230
+ # single-file grep the model targeted directly is already approved
231
+ # upstream, so it is searched normally.
232
+ next if !searching_file && secret_path_category(file)
139
233
  next if binary_file?(file)
140
234
 
141
235
  begin
@@ -57,7 +57,11 @@ module Rubino
57
57
  return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
58
58
  return "Error: edits must be a non-empty array" if !edits.is_a?(Array) || edits.empty?
59
59
 
60
- expanded = File.expand_path(file_path)
60
+ expanded = expand_workspace_path(file_path)
61
+ # SECRET/credential edits (#446) are no longer HARD-refused here — they
62
+ # are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
63
+ # APPROVED multi_edit of your .env actually applies, a denied/headless
64
+ # one never reaches #call. The workspace sandbox below is unchanged.
61
65
  return workspace_violation_message(file_path) unless within_workspace?(expanded)
62
66
  return "Error: File not found: #{file_path}" unless File.exist?(expanded)
63
67
 
@@ -65,7 +69,11 @@ module Rubino
65
69
  return gate
66
70
  end
67
71
 
68
- content = File.read(expanded)
72
+ # Read RAW bytes (binary) so the read-modify-write preserves every byte
73
+ # outside the matched spans — a non-UTF-8 byte on an untouched line is
74
+ # written back verbatim (#326). The model-supplied needles/replacements
75
+ # are matched and spliced as bytes too (see Base#to_match_bytes).
76
+ content = read_for_edit(expanded)
69
77
  working = content.dup
70
78
  applied_count = 0
71
79
 
@@ -80,30 +88,77 @@ module Rubino
80
88
  replace_all = edit["replace_all"] || edit[:replace_all] || false
81
89
 
82
90
  return "Error: edit ##{idx + 1} is missing old_string or new_string" if old_s.nil? || new_s.nil?
91
+ # Empty needle would match at every char boundary and corrupt the
92
+ # file under replace_all (#329a) — reject it like a missing string.
93
+ return "Error: edit ##{idx + 1}: old_string is empty" if old_s.empty?
83
94
  return "Error: edit ##{idx + 1}: old_string and new_string are identical" if old_s == new_s
84
- unless working.include?(old_s)
95
+
96
+ old_b = to_match_bytes(old_s)
97
+ new_b = to_match_bytes(new_s)
98
+
99
+ unless working.include?(old_b)
100
+ # Mental model was wrong — let the model's next read of this path
101
+ # bypass dedup and fetch fresh bytes for recovery (r5 B3).
102
+ @read_tracker&.note_edit_failure(expanded)
85
103
  return "Error: edit ##{idx + 1}: old_string not found (check whitespace; " \
86
104
  "remember edits see the result of prior edits)"
87
105
  end
88
106
 
89
- count = working.scan(old_s).size
107
+ count = working.scan(old_b).size
90
108
  if count > 1 && !replace_all
91
109
  return "Error: edit ##{idx + 1}: #{count} matches for old_string. " \
92
110
  "Add surrounding context to disambiguate, or set replace_all: true."
93
111
  end
94
112
 
95
113
  working = if replace_all
96
- working.gsub(old_s) { new_s }
114
+ working.gsub(old_b) { new_b }
97
115
  else
98
- working.sub(old_s) { new_s }
116
+ working.sub(old_b) { new_b }
99
117
  end
100
118
  applied_count += replace_all ? count : 1
101
119
  end
102
120
 
103
- File.write(expanded, working)
104
- "Applied #{edits.size} edit(s), #{applied_count} replacement(s) in #{file_path}"
121
+ # Crash-safe write: temp-in-same-dir + fsync + atomic rename. The tool's
122
+ # description advertises "atomically" make it true on the disk seam too,
123
+ # so a SIGINT/crash mid-flush leaves the ORIGINAL file intact (HIGH-1).
124
+ Util::AtomicFile.write_atomic(expanded, working)
125
+ # Refresh-on-own-write so a follow-up edit to this file isn't refused
126
+ # as "changed on disk since last read" (r5 B2).
127
+ @read_tracker&.note_write(expanded, working)
128
+ { output: "Applied #{edits.size} edit(s), #{applied_count} replacement(s) in #{file_path}",
129
+ metrics: "#{edits.size} edit#{"s" if edits.size != 1} · " \
130
+ "#{applied_count} replacement#{"s" if applied_count != 1}",
131
+ body: build_diff_preview(edits),
132
+ body_kind: :diff }
105
133
  rescue StandardError => e
106
- "Error: #{e.message}"
134
+ # Uniform with WriteTool/EditTool: a read-only target (Errno::EACCES)
135
+ # or any other filesystem error returns a clean message.
136
+ "Error editing #{file_path}: #{e.message}"
137
+ end
138
+
139
+ # Inline diff for the applied result, mirroring EditTool: per edit, the
140
+ # old lines as `-` then the new lines as `+`, edits separated by a blank
141
+ # line. Trimmed to the first MAX_DIFF_LINES so a big batch stays a
142
+ # preview (the edits all still apply).
143
+ MAX_DIFF_LINES = 16
144
+
145
+ private
146
+
147
+ def build_diff_preview(edits)
148
+ lines = []
149
+ edits.each_with_index do |edit, idx|
150
+ old_s = edit["old_string"] || edit[:old_string]
151
+ new_s = edit["new_string"] || edit[:new_string]
152
+ lines << "" unless idx.zero?
153
+ lines.concat(old_s.to_s.lines.map { |l| "- #{l.chomp}" })
154
+ lines.concat(new_s.to_s.lines.map { |l| "+ #{l.chomp}" })
155
+ end
156
+ if lines.size > MAX_DIFF_LINES
157
+ dropped = lines.size - MAX_DIFF_LINES
158
+ lines = lines.first(MAX_DIFF_LINES)
159
+ lines << " [… #{dropped} more line(s)]"
160
+ end
161
+ lines.join("\n")
107
162
  end
108
163
  end
109
164
  end
@@ -66,6 +66,11 @@ module Rubino
66
66
  hunks.each do |hunk|
67
67
  file_path = File.expand_path(hunk[:file], base_path)
68
68
 
69
+ # SECRET/credential patches (#446) are no longer HARD-refused here —
70
+ # they are gated UPSTREAM by Security::ApprovalPolicy#decide, which
71
+ # scans the patch's target paths and prompts (→ :ask) when ANY hunk
72
+ # touches a secret; an approved apply_patch proceeds, a denied/headless
73
+ # one never reaches #call. The workspace sandbox below is unchanged.
69
74
  unless within_workspace?(file_path)
70
75
  return [nil, workspace_violation_message(hunk[:file]) +
71
76
  " (no changes applied — apply_patch is two-phase)"]
@@ -93,7 +93,9 @@ module Rubino
93
93
  "reads documents and text. Inspect other kinds via the shell."
94
94
  end
95
95
 
96
- markdown = Rubino::Documents.to_markdown(cls.path, mime: cls.mime)
96
+ # Thread the cancel_token so a runaway/bomb conversion is interruptible
97
+ # mid-flight and bounded by the converter's wall-clock/element caps.
98
+ markdown = Rubino::Documents.to_markdown(cls.path, mime: cls.mime, cancel_token: @cancel_token)
97
99
  # No in-process converter (unknown format / optional gem absent): degrade
98
100
  # with the actionable shell-extraction hint, exactly like the preamble.
99
101
  # NEVER raise -- a missing gem must not break the turn.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Rubino
4
6
  module Tools
5
7
  # Reads a file with `cat -n` style line numbers, offset/limit windowing,
@@ -49,7 +51,12 @@ module Rubino
49
51
 
50
52
  return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
51
53
 
52
- expanded = File.expand_path(file_path)
54
+ expanded = expand_workspace_path(file_path)
55
+ # Reads are BROAD (#406): like Hermes/Claude/Codex, read resolves any
56
+ # NON-secret path with no prompt (clone-and-inspect). A SECRET/credential
57
+ # path (#446) is NOT refused here anymore — it is gated UPSTREAM by
58
+ # Security::ApprovalPolicy#decide (→ :ask), so an APPROVED read returns
59
+ # the real bytes while a denied/headless read never reaches #call.
53
60
  return "Error: File not found: #{file_path}" unless File.exist?(expanded)
54
61
  return "Error: Not a regular file: #{file_path}" unless File.file?(expanded)
55
62
 
@@ -64,20 +71,21 @@ module Rubino
64
71
  offset = 1 if offset < 1
65
72
  limit = DEFAULT_LIMIT if limit <= 0
66
73
 
67
- # Stash mtime BEFORE rendering so a slow render on a huge file doesn't
68
- # race with a concurrent writer — we want the mtime the model "saw",
69
- # not the one at end-of-render.
70
- mtime = File.mtime(expanded)
71
- @read_tracker&.register(expanded, mtime)
72
-
73
- # Re-reading the exact same window (same file, offset, limit, unchanged
74
- # mtime) within a turn just re-injects bytes already in context. Return
75
- # a short nudge instead so the conversation doesn't carry the same
76
- # content twice. A real edit bumps mtime, so legitimate re-reads pass.
77
- dup = @read_tracker&.register_window(expanded, offset, limit, mtime)
78
- if dup && dup > 1
74
+ # Stash mtime + content hash BEFORE rendering so a slow render on a huge
75
+ # file doesn't race with a concurrent writer — we want the state the
76
+ # model "saw", not the one at end-of-render. The hash is the single
77
+ # source of truth the edit-gate and dedup both consult.
78
+ mtime = File.mtime(expanded)
79
+ digest = Digest::SHA256.hexdigest(File.binread(expanded))
80
+ @read_tracker&.register(expanded, mtime, digest)
81
+
82
+ # Re-reading the exact same window of UNCHANGED bytes just re-injects
83
+ # content already in context. Skip the work with a nudge but only when
84
+ # the file still hashes the same, the TTL holds, and no edit-failure
85
+ # recovery is pending (those serve fresh content). See ReadTracker.
86
+ if @read_tracker&.duplicate_read?(expanded, offset, limit, digest)
79
87
  return { output: "[DUPLICATE READ] Exact repeat of an earlier read of #{file_path} " \
80
- "(lines #{offset}-#{offset + limit - 1}) this turn — reuse that result " \
88
+ "(lines #{offset}-#{offset + limit - 1}) — reuse that result " \
81
89
  "instead of re-reading.",
82
90
  metrics: "duplicate" }
83
91
  end
@@ -161,12 +169,22 @@ module Rubino
161
169
  last_shown = offset - 1
162
170
  byte_capped = false
163
171
 
164
- File.open(expanded, "r") do |io|
172
+ # Open as UTF-8 regardless of the process locale (#273): under a bare
173
+ # C/POSIX locale the default external encoding is US-ASCII, which would
174
+ # tag every line ASCII and force the scrub below to mangle perfectly
175
+ # valid UTF-8 file content. Pinning UTF-8 reads it correctly.
176
+ File.open(expanded, "r:UTF-8") do |io|
165
177
  io.each_line do |line|
166
178
  total_lines += 1
167
179
  next if total_lines < offset
168
180
  break if total_lines > last_line
169
181
 
182
+ # A single non-UTF-8 byte (e.g. a Latin-1 `é` in a legacy/EU
183
+ # source comment) would otherwise blow up `chomp`/`format` with
184
+ # "invalid byte sequence in UTF-8". Scrub it to the replacement
185
+ # char so the model can still read (and then edit) the file —
186
+ # lossy but graceful, instead of a blind read failure.
187
+ line = line.scrub unless line.valid_encoding?
170
188
  chomped = line.chomp
171
189
  chomped = chomped.byteslice(0, MAX_LINE_WIDTH) + "… [line truncated]" if chomped.bytesize > MAX_LINE_WIDTH
172
190
  out << format("%6d\t%s\n", total_lines, chomped)
@@ -1,29 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Rubino
4
6
  module Tools
5
- # Tracks which files the model has Read during the current session so
6
- # Edit and MultiEdit can refuse to write to a file the model never
7
- # opened. Without this, the model is free to "remember" the contents of
8
- # a file from training-time priors and edit a string that isn't actually
9
- # there, corrupting the file silently when the gsub goes through anyway
10
- # because the match happens to occur by accident.
7
+ # Single source of truth for per-path read/write state in a session, keyed
8
+ # on {content-hash, mtime}. Edit / MultiEdit / Write consult it before
9
+ # writing so the model can't edit a file it never opened (and would then be
10
+ # editing from training-time priors), and ReadTool consults it to skip
11
+ # re-emitting bytes already in context.
12
+ #
13
+ # WHY hash AND mtime (not mtime alone): the agent's OWN write bumps mtime,
14
+ # and so does a no-op `touch`, a CRLF normalisation, or a linter that
15
+ # rewrites the file to byte-identical content. mtime alone false-collides
16
+ # on all of those and trips the stale-read guard against the agent itself
17
+ # (r5 B2). We therefore record the content hash too: a path is "fresh" when
18
+ # EITHER the mtime is unchanged OR the on-disk content still hashes to what
19
+ # we last saw — so a touch / CRLF / linter rewrite to the same bytes does
20
+ # not force a re-read.
21
+ #
22
+ # REFRESH-ON-OWN-WRITE (r5 B2): a successful write/edit records the NEW
23
+ # content+mtime here via #note_write, so the agent's own writes are
24
+ # authoritative and the very next edit to the same file passes the gate
25
+ # instead of "changed on disk since last read".
11
26
  #
12
- # The tracker also stashes the mtime at the moment of read so the edit
13
- # path can detect "file changed under us" the user saving from a
14
- # separate editor, or another tool mutating the file after the read.
27
+ # DEDUP + RECOVERY (r5 B3): the duplicate-read nudge must SKIP WORK but
28
+ # NEVER serve stale bytes. #duplicate_read? returns true only when the same
29
+ # window was read AND the file still hashes to what that read saw AND a
30
+ # short TTL has not elapsed AND no edit-failure recovery is pending for the
31
+ # path. A failed edit calls #note_edit_failure(path); the next read of that
32
+ # path always serves fresh content (the dedup is suppressed once).
15
33
  #
16
- # Lifecycle: one instance PER SESSION (see .for_session), shared by
17
- # every turn's ToolExecutor in this process a read in turn 1 still
18
- # satisfies the gate in turn 2 while the file is unchanged on disk; any
19
- # mtime bump forces a re-read (#151). Resume in a NEW process does NOT
20
- # carry the tracker — the model must re-read after a resume before
21
- # editing. That's the conservative call: the file may have changed on
22
- # disk in the gap.
34
+ # Lifecycle: one instance PER SESSION (see .for_session), shared by every
35
+ # turn's ToolExecutor in this process. Resume in a NEW process does NOT
36
+ # carry the tracker the model must re-read after a resume before editing.
23
37
  class ReadTracker
24
- # One tracker per session id, lazily created, process-local. A nil or
25
- # empty id (one-shot calls without a session) gets a throwaway
26
- # instance, preserving the old per-executor behaviour there.
38
+ # How long a duplicate-read nudge stays valid. Past this the model may
39
+ # legitimately want the bytes back in context (long turn, summarised
40
+ # away), so we serve the content again rather than nudge.
41
+ DEDUP_TTL_SECONDS = 120
42
+
27
43
  @registry = {}
28
44
  @registry_mutex = Mutex.new
29
45
 
@@ -41,47 +57,149 @@ module Rubino
41
57
  end
42
58
 
43
59
  def initialize
44
- @reads = {}
45
- @windows = Hash.new(0)
60
+ # path => { mtime:, hash: } — the last state we KNOW for this path,
61
+ # whether from a read or from the agent's own write.
62
+ @state = {}
63
+ # [path, offset, limit] => { hash:, at: } — windows already served, so
64
+ # an identical re-read of unchanged bytes is a duplicate.
65
+ @windows = {}
66
+ # paths whose last edit failed: the next read bypasses dedup so a
67
+ # recovery re-read always returns fresh content.
68
+ @recover = {}
69
+ @mutex = Mutex.new
70
+ end
71
+
72
+ # Records a successful read: stash mtime + content hash so a later edit
73
+ # can confirm the file is unchanged, and a later read of the same window
74
+ # can be deduped.
75
+ def register(path, mtime, content_hash = nil)
76
+ key = canonical(path)
77
+ return unless key
78
+
79
+ @mutex.synchronize do
80
+ @state[key] = { mtime: mtime, hash: content_hash || hash_of(key) }
81
+ end
46
82
  end
47
83
 
48
- def register(path, mtime)
84
+ # Records the agent's OWN successful write/edit: the new content is now
85
+ # authoritative, so the next edit must NOT trip the stale-read guard
86
+ # (r5 B2). Pass the bytes just written so we hash exactly those and don't
87
+ # re-read the file (which could race a concurrent writer).
88
+ def note_write(path, new_content, mtime = nil)
49
89
  key = canonical(path)
50
90
  return unless key
51
91
 
52
- @reads[key] = mtime
92
+ @mutex.synchronize do
93
+ @state[key] = { mtime: mtime || file_mtime(key), hash: hash_bytes(new_content) }
94
+ # An applied write is the freshest possible content — clear any
95
+ # pending recovery flag and stale window records for this path.
96
+ @recover.delete(key)
97
+ @windows.reject! { |(wpath, _o, _l), _v| wpath == key }
98
+ end
53
99
  end
54
100
 
55
- # Records a read of an exact (path, offset, limit, mtime) window and
56
- # returns how many times that identical window has now been requested in
57
- # this session. >1 means the model is re-reading bytes it already has in
58
- # context — ReadTool uses this to return a [DUPLICATE READ] nudge instead
59
- # of re-emitting the same content. Keyed on mtime so a real edit between
60
- # reads (mtime bump) is NOT treated as a duplicate.
61
- def register_window(path, offset, limit, mtime)
101
+ # Flags that the last edit/multi_edit to +path+ FAILED, so the model's
102
+ # next read of it bypasses dedup and gets fresh disk content for recovery
103
+ # (r5 B3). One-shot: consumed by the next duplicate_read? check.
104
+ def note_edit_failure(path)
62
105
  key = canonical(path)
63
- return 1 unless key
106
+ return unless key
64
107
 
65
- sig = [key, offset.to_i, limit.to_i, mtime]
66
- @windows[sig] += 1
108
+ @mutex.synchronize { @recover[key] = true }
67
109
  end
68
110
 
69
111
  def seen?(path)
70
112
  key = canonical(path)
71
113
  return false unless key
72
114
 
73
- @reads.key?(key)
115
+ @mutex.synchronize { @state.key?(key) }
116
+ end
117
+
118
+ # True when the file on disk still matches what we last saw. The content
119
+ # hash is AUTHORITATIVE for change-detection: we never trust mtime alone to
120
+ # declare freshness, because on a coarse-mtime filesystem (Docker/linuxkit
121
+ # VM, some network mounts, two rapid consecutive writes) an external
122
+ # content change can land WITHOUT the mtime advancing — trusting mtime <=
123
+ # stored there would let an edit proceed on stale bytes and clobber the
124
+ # external change. So mtime is at most a hint: a NEWER mtime means recheck;
125
+ # an equal/older mtime still falls through to a hash comparison. The hash
126
+ # arm also lets a no-op touch / CRLF / linter rewrite to identical bytes
127
+ # pass without forcing a re-read (r5 B2). Returns false when we never saw
128
+ # the file, or it genuinely changed on disk.
129
+ def fresh?(path)
130
+ key = canonical(path)
131
+ return false unless key
132
+
133
+ @mutex.synchronize do
134
+ state = @state[key]
135
+ next false unless state
136
+
137
+ # Content hash is authoritative: equal/older mtime does NOT prove
138
+ # freshness on a coarse-mtime FS, so always confirm via the hash.
139
+ state[:hash] && state[:hash] == hash_of(key)
140
+ end
74
141
  end
75
142
 
76
143
  def mtime_at_read(path)
77
144
  key = canonical(path)
78
145
  return nil unless key
79
146
 
80
- @reads[key]
147
+ @mutex.synchronize { @state[key]&.fetch(:mtime, nil) }
148
+ end
149
+
150
+ # Records a read of an exact (path, offset, limit) window and reports
151
+ # whether this is a duplicate the model can reuse instead of re-reading.
152
+ # It is a duplicate ONLY when: the same window was served before, the file
153
+ # still hashes to what that window saw, the TTL hasn't elapsed, AND no
154
+ # edit-failure recovery is pending for the path. Otherwise it records the
155
+ # fresh window and returns false (serve the content).
156
+ def duplicate_read?(path, offset, limit, content_hash = nil)
157
+ key = canonical(path)
158
+ return false unless key
159
+
160
+ digest = content_hash || hash_of(key)
161
+ sig = [key, offset.to_i, limit.to_i]
162
+
163
+ @mutex.synchronize do
164
+ # A pending recovery (prior edit failed) always serves fresh content
165
+ # once, then clears.
166
+ if @recover.delete(key)
167
+ @windows[sig] = { hash: digest, at: monotonic }
168
+ next false
169
+ end
170
+
171
+ prior = @windows[sig]
172
+ if prior && prior[:hash] == digest && (monotonic - prior[:at]) <= DEDUP_TTL_SECONDS
173
+ true
174
+ else
175
+ @windows[sig] = { hash: digest, at: monotonic }
176
+ false
177
+ end
178
+ end
81
179
  end
82
180
 
83
181
  private
84
182
 
183
+ def monotonic
184
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
185
+ end
186
+
187
+ def file_mtime(key)
188
+ File.mtime(key)
189
+ rescue SystemCallError
190
+ nil
191
+ end
192
+
193
+ def hash_of(key)
194
+ hash_bytes(File.binread(key))
195
+ rescue SystemCallError
196
+ nil
197
+ end
198
+
199
+ def hash_bytes(bytes)
200
+ Digest::SHA256.hexdigest(bytes.to_s)
201
+ end
202
+
85
203
  # Same canonicalization rule as Base#canonical_path: realpath when the
86
204
  # file exists. Keeps the tracker stable across symlink components, so a
87
205
  # read via `./foo` and an edit via the full path both hit the same key.