rubino-agent 0.3.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 (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  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 +35 -3
  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/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -127,7 +127,14 @@ module Rubino
127
127
  "Valid subagents: #{available_subagent_names.join(", ")}."
128
128
  end
129
129
 
130
- if background
130
+ # Force FOREGROUND in headless one-shot (#380): a `rubino prompt`/-q run
131
+ # has no IdleCardHost to fold a background child's result back in, and the
132
+ # process exits the instant the parent's answer is ready — so a background
133
+ # subagent's result would be silently dropped (its notice sink is nil and
134
+ # its thread is killed on exit). Running synchronously returns the child's
135
+ # final text as THIS tool's result, so it lands in the parent transcript
136
+ # and is factored into the one-shot answer, making `task` reliable headless.
137
+ if background && !Rubino.headless?
131
138
  run_background(definition, prompt)
132
139
  else
133
140
  run_subagent(definition, prompt)
@@ -261,12 +268,28 @@ module Rubino
261
268
  # Records the terminal :completed state and notifies the parent.
262
269
  # Deliver-or-report for /agents steer (#140): a parked note the child
263
270
  # never got another turn to fold in would otherwise vanish silently —
264
- # the user believes the child was steered when it wasn't. Drain what's
265
- # left NOW (the child is done; nothing can consume it anymore) and say
266
- # so, on the parent UI and in the completion notice.
271
+ # the user believes the child was steered when it wasn't. Say so, on the
272
+ # parent UI and in the completion notice.
273
+ #
274
+ # H5 — the final drain now happens INSIDE #complete, under the SAME
275
+ # registry mutex that flips the status to terminal (and that #steer checks
276
+ # before pushing). The previous shape drained the queue HERE (InputQueue
277
+ # lock) and THEN called #complete (registry lock): a steer/answer arriving
278
+ # in that gap landed on an already-drained queue — dropped, missing from
279
+ # `undelivered`, yet reported delivered. Taking the drained notes from
280
+ # #complete's return closes that gap: a note is either drained here (and
281
+ # reported undelivered) or rejected by #steer (and reported not-delivered
282
+ # to its caller) — never silently lost.
267
283
  def record_completion(entry, text, sink, parent_ui)
268
- undelivered = entry.steer_queue&.drain || []
269
- BackgroundTasks.instance.complete(entry, status: :completed, result: text)
284
+ drained = BackgroundTasks.instance.complete(entry, status: :completed, result: text)
285
+ # Drop the gate-delivered answer COPIES (#457 regression): a /reply
286
+ # answer is delivered to the child via its ask gate AND mirrored onto the
287
+ # steer queue; when the child resumes via the gate and finishes without
288
+ # another turn, that mirror is drained here. It was NOT undelivered — the
289
+ # gate delivered it — so reporting it would surface a false "steer note
290
+ # not delivered" alarm on the happy path. GENUINE steer notes (no
291
+ # ANSWER_NOTE_PREFIX) still report undelivered, preserving #457's invariant.
292
+ undelivered = drained.reject { |n| n.to_s.start_with?(BackgroundTasks::ANSWER_NOTE_PREFIX) }
270
293
  notify(sink, completion_notice(entry, text, undelivered: undelivered))
271
294
  unless undelivered.empty?
272
295
  surface_completion(parent_ui,
@@ -428,7 +451,10 @@ module Rubino
428
451
  max_turns: definition.max_turns,
429
452
  ui: child_ui,
430
453
  agent_definition: definition,
431
- event_bus: Interaction::EventBus.new
454
+ event_bus: Interaction::EventBus.new,
455
+ # Tag the child's fresh session as subagent machinery so it's hidden
456
+ # from the user-facing /sessions picker + `sessions list` (item 2).
457
+ session_source: "subagent"
432
458
  )
433
459
  end
434
460
  end
@@ -572,7 +598,9 @@ module Rubino
572
598
  model_override: definition.resolved_model,
573
599
  max_turns: definition.max_turns,
574
600
  ui: nested_ui(definition),
575
- agent_definition: definition
601
+ agent_definition: definition,
602
+ # Hidden from the user-facing /sessions list/picker (item 2).
603
+ session_source: "subagent"
576
604
  )
577
605
  end
578
606
  end
@@ -55,6 +55,11 @@ module Rubino
55
55
  return "Error: file_path is required" if path.empty?
56
56
 
57
57
  expanded = File.expand_path(path)
58
+ # Like summarize_file, vision sends the raw bytes off to the auxiliary
59
+ # LLM, so an out-of-workspace image must be DENIED rather than read and
60
+ # exfiltrated. Checked before existence so a file outside the sandbox
61
+ # isn't even probed for presence (r5 MF-1 / r5c NEW-2).
62
+ return outside_workspace_message(path) if outside_workspace?(expanded)
58
63
  return "Error: file not found: #{path}" unless File.exist?(expanded)
59
64
  return "Error: not a regular file: #{path}" unless File.file?(expanded)
60
65
 
@@ -58,15 +58,16 @@ module Rubino
58
58
  def fetch_url(url, format:, redirects: 5)
59
59
  return "Error: Too many redirects" if redirects <= 0
60
60
 
61
- uri = URI.parse(url)
62
- uri = URI.parse("https://#{url}") unless uri.scheme
61
+ # Default a bare host to https:// (previous behaviour) before
62
+ # validating, so the SSRF guard sees a complete URL with a scheme.
63
+ url = "https://#{url}" unless URI.parse(url).scheme
64
+ safe = Rubino::Security::UrlSafety.validate!(url)
65
+ uri = safe[:uri]
63
66
 
64
- http = Net::HTTP.new(uri.host, uri.port)
65
- http.use_ssl = (uri.scheme == "https")
66
- http.open_timeout = TIMEOUT
67
- http.read_timeout = TIMEOUT
67
+ http = build_http(uri, safe[:addresses].first)
68
68
 
69
69
  request = Net::HTTP::Get.new(uri.request_uri)
70
+ request["Host"] = uri.host
70
71
  request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
71
72
  request["Accept"] = "text/html,text/plain,application/json"
72
73
 
@@ -74,7 +75,10 @@ module Rubino
74
75
 
75
76
  case response
76
77
  when Net::HTTPRedirection
77
- fetch_url(response["location"], format: format, redirects: redirects - 1)
78
+ # Re-validate the redirect target from scratch (resolve + IP check);
79
+ # never trust the Location header to point somewhere safe (SSRF).
80
+ next_url = absolute_redirect(uri, response["location"])
81
+ fetch_url(next_url, format: format, redirects: redirects - 1)
78
82
  when Net::HTTPSuccess
79
83
  content_type = response["content-type"].to_s
80
84
  return binary_refusal(url, content_type) if binary_content_type?(content_type)
@@ -97,10 +101,38 @@ module Rubino
97
101
  else
98
102
  "Error: HTTP #{response.code} - #{response.message}"
99
103
  end
104
+ rescue Rubino::Security::UrlSafety::BlockedURLError => e
105
+ "Refused for safety: #{e.message}"
100
106
  rescue StandardError => e
101
107
  "Error fetching URL: #{e.message}"
102
108
  end
103
109
 
110
+ # Build a Net::HTTP pinned to a validated IP so a DNS-rebinding server
111
+ # can't swap in a private address between our check and connect(). The
112
+ # Host header (set by the caller) and TLS SNI/verification still use the
113
+ # original hostname.
114
+ def build_http(uri, connect_ip)
115
+ http = Net::HTTP.new(connect_ip, uri.port)
116
+ http.use_ssl = (uri.scheme == "https")
117
+ if http.use_ssl?
118
+ http.ipaddr = connect_ip
119
+ # Net::HTTP derives SNI and certificate verification from #address;
120
+ # restore it to the hostname so TLS validates against the cert.
121
+ http.instance_variable_set(:@address, uri.host)
122
+ end
123
+ http.open_timeout = TIMEOUT
124
+ http.read_timeout = TIMEOUT
125
+ http
126
+ end
127
+
128
+ # Resolve a (possibly relative) Location header against the current URL,
129
+ # so the per-hop SSRF re-validation always runs on an absolute URL.
130
+ def absolute_redirect(current_uri, location)
131
+ URI.join(current_uri.to_s, location.to_s).to_s
132
+ rescue StandardError
133
+ location.to_s
134
+ end
135
+
104
136
  BINARY_TYPE_PATTERNS = [
105
137
  %r{\Aapplication/(pdf|zip|x-tar|x-gzip|x-bzip2|x-7z-compressed|x-rar|octet-stream|x-msdownload|vnd\.openxmlformats|vnd\.ms-)},
106
138
  %r{\Aimage/}, %r{\Aaudio/}, %r{\Avideo/},
@@ -7,7 +7,22 @@ require "json"
7
7
  module Rubino
8
8
  module Tools
9
9
  # Tool for performing web searches via external search APIs.
10
- # Supports Tavily, SearXNG, and a fallback DuckDuckGo scraper.
10
+ #
11
+ # Backends, in priority order:
12
+ # 1. Tavily (TAVILY_API_KEY) — high-quality, preferred
13
+ # 2. SearXNG (SEARXNG_URL) — self-hosted, full web index
14
+ # 3. DuckDuckGo Instant Answer JSON — keyless DEFAULT (no key needed)
15
+ #
16
+ # Why not scrape html/lite.duckduckgo.com keyless? DuckDuckGo now serves
17
+ # an anomaly/bot-challenge page (zero results) to datacenter egress IPs,
18
+ # so the old single-regex HTML scrape returned "No results" 100% of the
19
+ # time — a silent failure that looked like success. The Instant Answer
20
+ # JSON API (api.duckduckgo.com) is keyless, returns structured JSON, and
21
+ # is NOT bot-walled, so it is the robust keyless default. Its coverage is
22
+ # narrower (topic/entity answers, not a full web index): when it yields
23
+ # nothing we degrade to an EXPLICIT "search unavailable" message that
24
+ # points the user at TAVILY_API_KEY / SEARXNG_URL — never a silent
25
+ # zero-results-that-looks-like-a-real-answer.
11
26
  class WebSearchTool < Base
12
27
  def name
13
28
  "websearch"
@@ -106,51 +121,96 @@ module Rubino
106
121
  results.empty? ? "No results found for: #{query}" : results.join("\n\n")
107
122
  end
108
123
 
109
- # DuckDuckGo HTML scraper (fallback, no API key needed)
124
+ # Keyless default: DuckDuckGo Instant Answer JSON API.
125
+ # No API key, no bot-challenge for datacenter IPs. Defensive parse over
126
+ # Abstract / Results / RelatedTopics; explicit "unavailable" on no data.
110
127
  def search_ddg(query, max_results)
111
- uri = URI("https://html.duckduckgo.com/html/")
112
- body = URI.encode_www_form(q: query)
128
+ uri = URI("https://api.duckduckgo.com/")
129
+ uri.query = URI.encode_www_form(
130
+ q: query,
131
+ format: "json",
132
+ no_html: 1,
133
+ no_redirect: 1,
134
+ skip_disambig: 0,
135
+ t: "rubino"
136
+ )
113
137
 
114
- http = Net::HTTP.new(uri.host, uri.port)
115
- http.use_ssl = true
116
- request = Net::HTTP::Post.new(uri.path)
117
- request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
118
- request.body = body
138
+ body = get_json(uri)
139
+ data = parse_json(body)
140
+ return ddg_unavailable(query, "could not parse search response") if data.nil?
119
141
 
120
- response = http.request(request)
121
- parse_ddg_html(response.body, max_results)
142
+ results = ddg_results(data, max_results)
143
+ return results.join("\n\n") unless results.empty?
144
+
145
+ ddg_unavailable(query, "no instant-answer results")
122
146
  end
123
147
 
124
- def parse_ddg_html(html, max_results)
148
+ # Build a result list from the Instant Answer payload, most-specific
149
+ # signal first: the Abstract (a direct answer), then Results (official
150
+ # site links), then RelatedTopics (related entities). Topic groups
151
+ # (which nest a "Topics" array) are flattened.
152
+ def ddg_results(data, max_results)
125
153
  results = []
126
154
 
127
- # Extract result blocks
128
- html.scan(%r{<a rel="nofollow" class="result__a" href="([^"]+)"[^>]*>(.+?)</a>.*?<a class="result__snippet"[^>]*>(.+?)</a>}m) do |url, title, snippet|
129
- clean_title = title.gsub(/<[^>]+>/, "").strip
130
- clean_snippet = snippet.gsub(/<[^>]+>/, "").strip
131
- clean_url = url.strip
132
-
133
- # DuckDuckGo wraps URLs in redirects
134
- if clean_url.include?("uddg=")
135
- clean_url = begin
136
- URI.decode_www_form_component(clean_url.match(/uddg=([^&]+)/)[1])
137
- rescue StandardError
138
- clean_url
139
- end
140
- end
141
-
142
- results << format_result(clean_title, clean_url, clean_snippet)
143
- break if results.size >= max_results
155
+ abstract = data["AbstractText"].to_s.strip
156
+ abstract = data["Abstract"].to_s.strip if abstract.empty?
157
+ if !abstract.empty? && data["AbstractURL"].to_s.strip != ""
158
+ results << format_result(
159
+ data["Heading"].to_s.strip.empty? ? "Answer" : data["Heading"].to_s.strip,
160
+ data["AbstractURL"], abstract
161
+ )
144
162
  end
145
163
 
146
- results.empty? ? "No results found (DDG fallback)" : results.join("\n\n")
164
+ ddg_topics(data["Results"]).each { |t| results << ddg_topic_result(t) }
165
+ ddg_topics(data["RelatedTopics"]).each { |t| results << ddg_topic_result(t) }
166
+
167
+ results.compact.first(max_results)
168
+ end
169
+
170
+ # Flatten DDG's RelatedTopics: some entries are leaf topics (have
171
+ # FirstURL), others are category groups carrying a nested "Topics" array.
172
+ def ddg_topics(raw)
173
+ Array(raw).flat_map do |entry|
174
+ next [] unless entry.is_a?(Hash)
175
+
176
+ entry.key?("Topics") ? Array(entry["Topics"]) : [entry]
177
+ end
178
+ end
179
+
180
+ def ddg_topic_result(topic)
181
+ return nil unless topic.is_a?(Hash)
182
+
183
+ url = topic["FirstURL"].to_s.strip
184
+ text = topic["Text"].to_s.strip
185
+ return nil if url.empty? || text.empty?
186
+
187
+ # The leading sentence of Text doubles as the title; keep the whole
188
+ # thing as the snippet so no information is lost.
189
+ title = text.split(" - ", 2).first.to_s.strip
190
+ title = text if title.empty?
191
+ format_result(title, url, text)
192
+ end
193
+
194
+ def ddg_unavailable(query, reason)
195
+ "Web search unavailable for \"#{query}\" (#{reason}).\n\n" \
196
+ "The keyless DuckDuckGo Instant Answer backend only covers topic/" \
197
+ "entity queries and returned nothing for this one. For full web " \
198
+ "search, set TAVILY_API_KEY (https://tavily.com) or SEARXNG_URL to " \
199
+ "a SearXNG instance, then retry."
147
200
  end
148
201
 
149
202
  def format_result(title, url, snippet)
150
203
  "**#{title}**\n#{url}\n#{snippet}"
151
204
  end
152
205
 
206
+ def parse_json(body)
207
+ JSON.parse(body)
208
+ rescue JSON::ParserError
209
+ nil
210
+ end
211
+
153
212
  def post_json(uri, body)
213
+ Rubino::Security::UrlSafety.validate!(uri.to_s)
154
214
  http = Net::HTTP.new(uri.host, uri.port)
155
215
  http.use_ssl = (uri.scheme == "https")
156
216
  http.open_timeout = 10
@@ -164,6 +224,7 @@ module Rubino
164
224
  end
165
225
 
166
226
  def get_json(uri)
227
+ Rubino::Security::UrlSafety.validate!(uri.to_s)
167
228
  http = Net::HTTP.new(uri.host, uri.port)
168
229
  http.use_ssl = (uri.scheme == "https")
169
230
  http.open_timeout = 10
@@ -171,6 +232,7 @@ module Rubino
171
232
 
172
233
  request = Net::HTTP::Get.new(uri.request_uri)
173
234
  request["Accept"] = "application/json"
235
+ request["User-Agent"] = "Rubino/#{Rubino::VERSION}"
174
236
 
175
237
  http.request(request).body
176
238
  end
@@ -40,13 +40,32 @@ module Rubino
40
40
 
41
41
  return "Error: file_path is required" if file_path.nil? || file_path.to_s.empty?
42
42
 
43
- expanded = File.expand_path(file_path)
43
+ expanded = expand_workspace_path(file_path)
44
+ # SECRET/credential writes (#446) are no longer HARD-refused here — they
45
+ # are gated UPSTREAM by Security::ApprovalPolicy#decide (→ :ask): an
46
+ # APPROVED write to your .env actually writes, a denied/headless one
47
+ # never reaches #call. The workspace sandbox below is unchanged.
44
48
  return workspace_violation_message(file_path) unless within_workspace?(expanded)
45
49
 
46
- FileUtils.mkdir_p(File.dirname(expanded))
47
-
48
50
  existed = File.exist?(expanded)
49
- File.write(expanded, content)
51
+ # Read-before-overwrite guard (r5 MF-2, Claude Code's rule): writing
52
+ # over an EXISTING file requires that the model read it this session, so
53
+ # a blind `write` can't silently clobber content the model never saw
54
+ # (the near-data-loss path). NEW files skip the guard. No tracker
55
+ # injected → no guard (single-tool unit tests / one-shot MCP).
56
+ if existed && (guard = overwrite_guard_error(expanded, file_path))
57
+ return guard
58
+ end
59
+
60
+ FileUtils.mkdir_p(File.dirname(expanded))
61
+ # Crash-safe write: temp-in-same-dir + fsync + atomic rename, so a
62
+ # SIGINT/SIGTERM/OOM-kill mid-write leaves the ORIGINAL file intact
63
+ # rather than a torn/truncated one (HIGH-1). The bare File.write here
64
+ # could be cut mid-flush, destroying the user's existing content.
65
+ Util::AtomicFile.write_atomic(expanded, content)
66
+ # Refresh-on-own-write so a later edit of this just-written file passes
67
+ # the read-gate (r5 B2) and a re-read sees it as authoritative.
68
+ @read_tracker&.note_write(expanded, content)
50
69
 
51
70
  verb = existed ? "overwrote" : "created"
52
71
  bytes = content.to_s.bytesize
data/lib/rubino/ui/api.rb CHANGED
@@ -52,6 +52,15 @@ module Rubino
52
52
  !@gate.nil? && !@recorder.nil?
53
53
  end
54
54
 
55
+ # ToolExecutor reads this to decide whether a tool needing approval can be
56
+ # put in front of a human (#260). The API adapter CAN — via the HTTP
57
+ # ApprovalGate — but only when a gate + recorder are wired; a gate-less
58
+ # embed/test run has no one to answer, so it fails closed too instead of
59
+ # silently auto-approving a write/shell command.
60
+ def interactive?
61
+ blocking_human_input?
62
+ end
63
+
55
64
  def info(message) = emit_event(:info, message: message)
56
65
 
57
66
  def success(message) = emit_event(:success, message: message)
@@ -81,7 +90,7 @@ module Rubino
81
90
  end
82
91
 
83
92
  def tool_body(text, kind: :plain) = emit_event(:tool_body, text: text, kind: kind)
84
- def tool_chunk(name, chunk) = emit_event(:tool_chunk, name: name, chunk: chunk)
93
+ def tool_chunk(name, chunk, kind: :plain) = emit_event(:tool_chunk, name: name, chunk: chunk, kind: kind)
85
94
  def tool_finished(name, result: nil) = emit_event(:tool_finished, name: name)
86
95
  def compression_started(at: nil) = emit_event(:compression_started, at: at)
87
96
 
@@ -139,6 +139,17 @@ module Rubino
139
139
  raise NotImplementedError, "#{self.class}#confirm not implemented"
140
140
  end
141
141
 
142
+ # True when this adapter can actually put an approval prompt in front of a
143
+ # human and block for an answer — a real interactive terminal (UI::CLI) or
144
+ # the HTTP approval gate (UI::API). The headless one-shot adapter
145
+ # (UI::Null) returns false, which is the signal ToolExecutor uses to FAIL
146
+ # CLOSED: a tool that needs approval is DENIED rather than auto-run, since
147
+ # there is no one to ask (the security floor behind #260). Default true so
148
+ # any custom adapter that hosts a human keeps the prompt path.
149
+ def interactive?
150
+ true
151
+ end
152
+
142
153
  # A destructive yes/No confirm, default No — distinct from the tool-approval
143
154
  # #confirm above (#218). Used for the in-chat/CLI destructive verbs (session
144
155
  # delete, memory forget), so only the CLI and Null adapters implement it;