rubino-agent 0.3.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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +115 -0
  4. data/.rubocop_todo.yml +955 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +97 -0
  7. data/CHANGELOG.md +344 -0
  8. data/CONTRIBUTING.md +69 -0
  9. data/LICENSE +21 -0
  10. data/README.md +200 -0
  11. data/Rakefile +8 -0
  12. data/docs/agents.md +190 -0
  13. data/docs/api/v1.md +414 -0
  14. data/docs/architecture.md +177 -0
  15. data/docs/commands.md +375 -0
  16. data/docs/configuration.md +590 -0
  17. data/docs/getting-started.md +143 -0
  18. data/docs/jobs.md +332 -0
  19. data/docs/mcp.md +128 -0
  20. data/docs/memory.md +98 -0
  21. data/docs/models-and-keys.md +173 -0
  22. data/docs/oauth-providers.md +145 -0
  23. data/docs/plugins.md +195 -0
  24. data/docs/security.md +145 -0
  25. data/docs/skills.md +322 -0
  26. data/docs/tools.md +395 -0
  27. data/docs/troubleshooting.md +73 -0
  28. data/exe/rubino +9 -0
  29. data/install.sh +275 -0
  30. data/lib/rubino/active_skill.rb +50 -0
  31. data/lib/rubino/agent/agent_registry.rb +120 -0
  32. data/lib/rubino/agent/backoff_policy.rb +116 -0
  33. data/lib/rubino/agent/definition.rb +128 -0
  34. data/lib/rubino/agent/degenerate_recovery.rb +271 -0
  35. data/lib/rubino/agent/fallback_chain.rb +194 -0
  36. data/lib/rubino/agent/iteration_budget.rb +50 -0
  37. data/lib/rubino/agent/loop.rb +617 -0
  38. data/lib/rubino/agent/model_call_runner.rb +383 -0
  39. data/lib/rubino/agent/prompts/build.txt +69 -0
  40. data/lib/rubino/agent/prompts/compaction.txt +20 -0
  41. data/lib/rubino/agent/prompts/explore.txt +19 -0
  42. data/lib/rubino/agent/prompts/general.txt +20 -0
  43. data/lib/rubino/agent/prompts/plan.txt +31 -0
  44. data/lib/rubino/agent/response_validator.rb +70 -0
  45. data/lib/rubino/agent/router.rb +65 -0
  46. data/lib/rubino/agent/runner.rb +195 -0
  47. data/lib/rubino/agent/tool_executor.rb +402 -0
  48. data/lib/rubino/agent/truncation_continuation.rb +137 -0
  49. data/lib/rubino/api/middleware/auth.rb +43 -0
  50. data/lib/rubino/api/middleware/error_handler.rb +65 -0
  51. data/lib/rubino/api/middleware/json_parser.rb +100 -0
  52. data/lib/rubino/api/middleware/observability.rb +59 -0
  53. data/lib/rubino/api/middleware/rate_limit.rb +136 -0
  54. data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
  55. data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
  56. data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
  57. data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
  58. data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
  59. data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
  60. data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
  61. data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
  62. data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
  63. data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
  64. data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
  65. data/lib/rubino/api/operations/files/read_operation.rb +40 -0
  66. data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
  67. data/lib/rubino/api/operations/health_operation.rb +46 -0
  68. data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
  69. data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
  70. data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
  71. data/lib/rubino/api/operations/metrics_operation.rb +18 -0
  72. data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
  73. data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
  74. data/lib/rubino/api/operations/models/list_operation.rb +45 -0
  75. data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
  76. data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
  77. data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
  78. data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
  79. data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
  80. data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
  81. data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
  82. data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
  83. data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
  84. data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
  85. data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
  86. data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
  87. data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
  88. data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
  89. data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
  90. data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
  91. data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
  92. data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
  93. data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
  94. data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
  95. data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
  96. data/lib/rubino/api/request.rb +54 -0
  97. data/lib/rubino/api/responses.rb +64 -0
  98. data/lib/rubino/api/router.rb +72 -0
  99. data/lib/rubino/api/schemas.rb +103 -0
  100. data/lib/rubino/api/server.rb +102 -0
  101. data/lib/rubino/api/tls.rb +108 -0
  102. data/lib/rubino/attachments/classification.rb +16 -0
  103. data/lib/rubino/attachments/classify.rb +171 -0
  104. data/lib/rubino/attachments/defang.rb +47 -0
  105. data/lib/rubino/attachments/policy.rb +36 -0
  106. data/lib/rubino/attachments/preamble.rb +120 -0
  107. data/lib/rubino/boot/encryption_key.rb +32 -0
  108. data/lib/rubino/cli/chat/bang_shell.rb +257 -0
  109. data/lib/rubino/cli/chat/completion_builder.rb +290 -0
  110. data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
  111. data/lib/rubino/cli/chat/image_inbox.rb +168 -0
  112. data/lib/rubino/cli/chat/session_resolver.rb +176 -0
  113. data/lib/rubino/cli/chat_command.rb +1674 -0
  114. data/lib/rubino/cli/commands.rb +250 -0
  115. data/lib/rubino/cli/config_command.rb +96 -0
  116. data/lib/rubino/cli/doctor_command.rb +251 -0
  117. data/lib/rubino/cli/jobs_command.rb +60 -0
  118. data/lib/rubino/cli/memory_command.rb +135 -0
  119. data/lib/rubino/cli/onboarding_wizard.rb +207 -0
  120. data/lib/rubino/cli/server_command.rb +139 -0
  121. data/lib/rubino/cli/session_command.rb +125 -0
  122. data/lib/rubino/cli/setup_command.rb +107 -0
  123. data/lib/rubino/cli/skills_command.rb +85 -0
  124. data/lib/rubino/cli/tools_command.rb +81 -0
  125. data/lib/rubino/cli/trust_gate.rb +71 -0
  126. data/lib/rubino/commands/built_ins.rb +46 -0
  127. data/lib/rubino/commands/command.rb +116 -0
  128. data/lib/rubino/commands/executor.rb +550 -0
  129. data/lib/rubino/commands/handlers/agents.rb +510 -0
  130. data/lib/rubino/commands/handlers/config.rb +88 -0
  131. data/lib/rubino/commands/handlers/help.rb +148 -0
  132. data/lib/rubino/commands/handlers/jobs.rb +71 -0
  133. data/lib/rubino/commands/handlers/mcp.rb +229 -0
  134. data/lib/rubino/commands/handlers/memory.rb +200 -0
  135. data/lib/rubino/commands/handlers/sessions.rb +207 -0
  136. data/lib/rubino/commands/handlers/skills.rb +195 -0
  137. data/lib/rubino/commands/handlers/status.rb +211 -0
  138. data/lib/rubino/commands/loader.rb +90 -0
  139. data/lib/rubino/config/configuration.rb +455 -0
  140. data/lib/rubino/config/defaults.rb +569 -0
  141. data/lib/rubino/config/loader.rb +115 -0
  142. data/lib/rubino/config/reasoning_prefs.rb +67 -0
  143. data/lib/rubino/config/writer.rb +72 -0
  144. data/lib/rubino/context/compressor.rb +149 -0
  145. data/lib/rubino/context/environment_inspector.rb +176 -0
  146. data/lib/rubino/context/file_discovery.rb +45 -0
  147. data/lib/rubino/context/message_boundary.rb +39 -0
  148. data/lib/rubino/context/prompt_assembler.rb +382 -0
  149. data/lib/rubino/context/summary_builder.rb +159 -0
  150. data/lib/rubino/context/token_budget.rb +68 -0
  151. data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
  152. data/lib/rubino/database/connection.rb +77 -0
  153. data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
  154. data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
  155. data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
  156. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
  157. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
  158. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
  159. data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
  160. data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
  161. data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
  162. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
  163. data/lib/rubino/database/migrator.rb +48 -0
  164. data/lib/rubino/documents/converters/csv.rb +79 -0
  165. data/lib/rubino/documents/converters/docx.rb +129 -0
  166. data/lib/rubino/documents/converters/html.rb +28 -0
  167. data/lib/rubino/documents/converters/json.rb +35 -0
  168. data/lib/rubino/documents/converters/pdf.rb +59 -0
  169. data/lib/rubino/documents/converters/plain.rb +68 -0
  170. data/lib/rubino/documents/converters/pptx.rb +64 -0
  171. data/lib/rubino/documents/converters/xlsx.rb +62 -0
  172. data/lib/rubino/documents/converters/xml.rb +45 -0
  173. data/lib/rubino/documents/html.rb +71 -0
  174. data/lib/rubino/documents/registry.rb +68 -0
  175. data/lib/rubino/documents/table.rb +63 -0
  176. data/lib/rubino/documents.rb +50 -0
  177. data/lib/rubino/errors.rb +119 -0
  178. data/lib/rubino/files/workspace.rb +93 -0
  179. data/lib/rubino/interaction/cancel_token.rb +43 -0
  180. data/lib/rubino/interaction/clipboard_image.rb +84 -0
  181. data/lib/rubino/interaction/event_bus.rb +48 -0
  182. data/lib/rubino/interaction/events.rb +101 -0
  183. data/lib/rubino/interaction/image_input.rb +127 -0
  184. data/lib/rubino/interaction/input_queue.rb +117 -0
  185. data/lib/rubino/interaction/lifecycle.rb +299 -0
  186. data/lib/rubino/interaction/probe.rb +65 -0
  187. data/lib/rubino/interaction/state.rb +56 -0
  188. data/lib/rubino/jobs/cron_job_repository.rb +75 -0
  189. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
  190. data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
  191. data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
  192. data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
  193. data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
  194. data/lib/rubino/jobs/queue.rb +184 -0
  195. data/lib/rubino/jobs/registry.rb +45 -0
  196. data/lib/rubino/jobs/runner.rb +79 -0
  197. data/lib/rubino/jobs/scheduler.rb +138 -0
  198. data/lib/rubino/jobs/webhook_delivery.rb +225 -0
  199. data/lib/rubino/jobs/worker.rb +59 -0
  200. data/lib/rubino/llm/adapter_factory.rb +47 -0
  201. data/lib/rubino/llm/adapter_response.rb +65 -0
  202. data/lib/rubino/llm/auxiliary_client.rb +61 -0
  203. data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
  204. data/lib/rubino/llm/content_builder.rb +55 -0
  205. data/lib/rubino/llm/credential_check.rb +93 -0
  206. data/lib/rubino/llm/error_classifier.rb +364 -0
  207. data/lib/rubino/llm/fake_provider.rb +292 -0
  208. data/lib/rubino/llm/inline_think_filter.rb +58 -0
  209. data/lib/rubino/llm/model_catalog.rb +29 -0
  210. data/lib/rubino/llm/provider_resolver.rb +48 -0
  211. data/lib/rubino/llm/reasoning_manager.rb +100 -0
  212. data/lib/rubino/llm/request.rb +56 -0
  213. data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
  214. data/lib/rubino/llm/scenario_loader.rb +68 -0
  215. data/lib/rubino/llm/scenario_selector.rb +80 -0
  216. data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
  217. data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
  218. data/lib/rubino/llm/scenarios/analysis.yml +501 -0
  219. data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
  220. data/lib/rubino/llm/scenarios/failure.yml +65 -0
  221. data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
  222. data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
  223. data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
  224. data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
  225. data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
  226. data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
  227. data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
  228. data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
  229. data/lib/rubino/llm/thinking_support.rb +84 -0
  230. data/lib/rubino/llm/tool_bridge.rb +89 -0
  231. data/lib/rubino/logger.rb +99 -0
  232. data/lib/rubino/mcp/manager.rb +180 -0
  233. data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
  234. data/lib/rubino/mcp.rb +57 -0
  235. data/lib/rubino/memory/backend.rb +104 -0
  236. data/lib/rubino/memory/backends/default.rb +101 -0
  237. data/lib/rubino/memory/backends/sqlite.rb +653 -0
  238. data/lib/rubino/memory/backends.rb +53 -0
  239. data/lib/rubino/memory/deduplicator.rb +74 -0
  240. data/lib/rubino/memory/extractor.rb +85 -0
  241. data/lib/rubino/memory/flusher.rb +31 -0
  242. data/lib/rubino/memory/retriever.rb +50 -0
  243. data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
  244. data/lib/rubino/memory/sqlite_graph.rb +154 -0
  245. data/lib/rubino/memory/store.rb +228 -0
  246. data/lib/rubino/memory/threat_scanner.rb +68 -0
  247. data/lib/rubino/metrics.rb +175 -0
  248. data/lib/rubino/modes.rb +93 -0
  249. data/lib/rubino/oauth/connection_repository.rb +95 -0
  250. data/lib/rubino/oauth/provider/github.rb +75 -0
  251. data/lib/rubino/oauth/provider/google.rb +59 -0
  252. data/lib/rubino/oauth/provider.rb +149 -0
  253. data/lib/rubino/oauth/registry.rb +86 -0
  254. data/lib/rubino/oauth/token_encryptor.rb +87 -0
  255. data/lib/rubino/plugins/registry.rb +75 -0
  256. data/lib/rubino/plugins.rb +86 -0
  257. data/lib/rubino/run/approval_gate.rb +243 -0
  258. data/lib/rubino/run/attachment_downloader.rb +166 -0
  259. data/lib/rubino/run/event_store.rb +74 -0
  260. data/lib/rubino/run/executor.rb +383 -0
  261. data/lib/rubino/run/gate_registry.rb +39 -0
  262. data/lib/rubino/run/recorder.rb +69 -0
  263. data/lib/rubino/run/repository.rb +118 -0
  264. data/lib/rubino/run/session_approval_cache.rb +118 -0
  265. data/lib/rubino/security/allowlist_persister.rb +55 -0
  266. data/lib/rubino/security/approval_policy.rb +227 -0
  267. data/lib/rubino/security/command_allowlist.rb +24 -0
  268. data/lib/rubino/security/dangerous_patterns.rb +118 -0
  269. data/lib/rubino/security/deny_persister.rb +73 -0
  270. data/lib/rubino/security/doom_loop_detector.rb +43 -0
  271. data/lib/rubino/security/hardline_guard.rb +105 -0
  272. data/lib/rubino/security/pattern_matcher.rb +62 -0
  273. data/lib/rubino/security/prefix_deriver.rb +124 -0
  274. data/lib/rubino/security/readonly_commands.rb +211 -0
  275. data/lib/rubino/session/exporter.rb +101 -0
  276. data/lib/rubino/session/message.rb +77 -0
  277. data/lib/rubino/session/repository.rb +295 -0
  278. data/lib/rubino/session/store.rb +198 -0
  279. data/lib/rubino/session/summary_store.rb +65 -0
  280. data/lib/rubino/skills/prompt_index.rb +85 -0
  281. data/lib/rubino/skills/registry.rb +208 -0
  282. data/lib/rubino/skills/skill.rb +176 -0
  283. data/lib/rubino/skills/skill_tool.rb +215 -0
  284. data/lib/rubino/skills/state_repository.rb +37 -0
  285. data/lib/rubino/skills/toggle.rb +26 -0
  286. data/lib/rubino/tools/answer_child_tool.rb +83 -0
  287. data/lib/rubino/tools/ask_parent_tool.rb +232 -0
  288. data/lib/rubino/tools/attach_file_tool.rb +120 -0
  289. data/lib/rubino/tools/background_tasks.rb +520 -0
  290. data/lib/rubino/tools/base.rb +222 -0
  291. data/lib/rubino/tools/custom_tool_loader.rb +119 -0
  292. data/lib/rubino/tools/edit_tool.rb +122 -0
  293. data/lib/rubino/tools/git_tool.rb +71 -0
  294. data/lib/rubino/tools/github_tool.rb +233 -0
  295. data/lib/rubino/tools/glob_tool.rb +69 -0
  296. data/lib/rubino/tools/grep_tool.rb +206 -0
  297. data/lib/rubino/tools/memory_tool.rb +184 -0
  298. data/lib/rubino/tools/multi_edit_tool.rb +110 -0
  299. data/lib/rubino/tools/patch_tool.rb +260 -0
  300. data/lib/rubino/tools/probe_tool.rb +175 -0
  301. data/lib/rubino/tools/question_tool.rb +128 -0
  302. data/lib/rubino/tools/read_attachment_tool.rb +180 -0
  303. data/lib/rubino/tools/read_tool.rb +212 -0
  304. data/lib/rubino/tools/read_tracker.rb +98 -0
  305. data/lib/rubino/tools/registry.rb +166 -0
  306. data/lib/rubino/tools/result.rb +113 -0
  307. data/lib/rubino/tools/ruby_tool.rb +0 -0
  308. data/lib/rubino/tools/session_search_tool.rb +103 -0
  309. data/lib/rubino/tools/shell_input_tool.rb +96 -0
  310. data/lib/rubino/tools/shell_kill_tool.rb +76 -0
  311. data/lib/rubino/tools/shell_output_tool.rb +72 -0
  312. data/lib/rubino/tools/shell_registry.rb +158 -0
  313. data/lib/rubino/tools/shell_tail_tool.rb +118 -0
  314. data/lib/rubino/tools/shell_tool.rb +330 -0
  315. data/lib/rubino/tools/steer_tool.rb +118 -0
  316. data/lib/rubino/tools/subagent_probe.rb +89 -0
  317. data/lib/rubino/tools/summarize_file_tool.rb +182 -0
  318. data/lib/rubino/tools/task_result_tool.rb +90 -0
  319. data/lib/rubino/tools/task_stop_tool.rb +80 -0
  320. data/lib/rubino/tools/task_tool.rb +622 -0
  321. data/lib/rubino/tools/test_tool.rb +454 -0
  322. data/lib/rubino/tools/todo_tool.rb +93 -0
  323. data/lib/rubino/tools/tool_call_repository.rb +33 -0
  324. data/lib/rubino/tools/vision_tool.rb +85 -0
  325. data/lib/rubino/tools/webfetch_tool.rb +153 -0
  326. data/lib/rubino/tools/websearch_tool.rb +179 -0
  327. data/lib/rubino/tools/write_tool.rb +61 -0
  328. data/lib/rubino/trust.rb +88 -0
  329. data/lib/rubino/ui/api.rb +296 -0
  330. data/lib/rubino/ui/base.rb +252 -0
  331. data/lib/rubino/ui/bottom_composer.rb +1599 -0
  332. data/lib/rubino/ui/cli.rb +1987 -0
  333. data/lib/rubino/ui/completion_menu.rb +321 -0
  334. data/lib/rubino/ui/completion_source.rb +284 -0
  335. data/lib/rubino/ui/escape_reader.rb +169 -0
  336. data/lib/rubino/ui/indented_io.rb +88 -0
  337. data/lib/rubino/ui/input_history.rb +108 -0
  338. data/lib/rubino/ui/live_region.rb +183 -0
  339. data/lib/rubino/ui/markdown_renderer.rb +506 -0
  340. data/lib/rubino/ui/notifier.rb +163 -0
  341. data/lib/rubino/ui/null.rb +195 -0
  342. data/lib/rubino/ui/paste_store.rb +176 -0
  343. data/lib/rubino/ui/printer_base.rb +79 -0
  344. data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
  345. data/lib/rubino/ui/queued_indicators.rb +66 -0
  346. data/lib/rubino/ui/status_bar.rb +100 -0
  347. data/lib/rubino/ui/stdout_proxy.rb +161 -0
  348. data/lib/rubino/ui/streaming_markdown.rb +186 -0
  349. data/lib/rubino/ui/subagent_cards.rb +134 -0
  350. data/lib/rubino/ui/subagent_view.rb +255 -0
  351. data/lib/rubino/ui.rb +21 -0
  352. data/lib/rubino/update_check.rb +187 -0
  353. data/lib/rubino/util/duration.rb +23 -0
  354. data/lib/rubino/util/hyperlink.rb +105 -0
  355. data/lib/rubino/util/output.rb +145 -0
  356. data/lib/rubino/util/secrets_mask.rb +83 -0
  357. data/lib/rubino/version.rb +5 -0
  358. data/lib/rubino/workspace.rb +85 -0
  359. data/lib/rubino-agent.rb +5 -0
  360. data/lib/rubino.rb +318 -0
  361. data/mise.toml +2 -0
  362. data/rubino-agent.gemspec +103 -0
  363. data/skills/ruby-expert/SKILL.md +67 -0
  364. data/skills/ruby-expert/references/concurrency.md +357 -0
  365. data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
  366. data/skills/ruby-expert/references/errors-and-types.md +460 -0
  367. data/skills/ruby-expert/references/gem-authoring.md +459 -0
  368. data/skills/ruby-expert/references/language-idioms.md +465 -0
  369. data/skills/ruby-expert/references/metaprogramming.md +339 -0
  370. data/skills/ruby-expert/references/oo-design.md +553 -0
  371. data/skills/ruby-expert/references/performance.md +383 -0
  372. data/skills/ruby-expert/references/rails.md +424 -0
  373. data/skills/ruby-expert/references/security.md +404 -0
  374. data/skills/ruby-expert/references/testing.md +473 -0
  375. data/skills/ruby-expert/references/tooling.md +466 -0
  376. metadata +856 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Hardline (unconditional) blocklist — a floor BELOW yolo.
6
+ #
7
+ # Commands so catastrophic they must NEVER run via the agent, regardless
8
+ # of --yolo, skip-approvals mode, a permissions:allow rule, or a
9
+ # command_allowlist entry. Opting into yolo is the user trusting the agent
10
+ # to move fast on their files and services — NOT trusting it to wipe the
11
+ # disk or power the box off.
12
+ #
13
+ # The list is deliberately TINY: only things with no recovery path —
14
+ # filesystem destruction rooted at / (or ~), raw block-device overwrites,
15
+ # filesystem format, kernel shutdown/reboot, and fork-bomb / kill-all DoS.
16
+ # Recoverable-but-costly operations (git reset --hard, rm -rf /tmp/x,
17
+ # chmod -R 777, curl|sh) DO NOT belong here — they stay in the dangerous-
18
+ # pattern layer where yolo/approval can pass them through. Adding anything
19
+ # recoverable here is a false-positive that blocks legitimate work.
20
+ #
21
+ # Mirrors the reference approval module: HARDLINE_PATTERNS,
22
+ # detect_hardline_command, the sudo-stdin guard, and the
23
+ # "tiny, no recovery path" guidance.
24
+ module HardlineGuard
25
+ # Start-of-command anchor: matches positions where a shell begins
26
+ # parsing a new command (start of string, after a separator, after a
27
+ # subshell opener), optionally consuming leading wrappers (sudo, env
28
+ # VAR=VAL, exec/nohup/setsid/time) so we don't false-positive on
29
+ # "echo reboot" or "grep shutdown log". Mirrors approval.py:_CMDPOS.
30
+ CMDPOS = /(?:^|[;&|\n`]|\$\()\s*(?:sudo\s+(?:-\S+\s+)*)?(?:env\s+(?:\w+=\S*\s+)*)?(?:(?:exec|nohup|setsid|time)\s+)*\s*/.source.freeze
31
+
32
+ # [regex, human description]. Matched against the lowercased, whitespace-
33
+ # normalized command. KEEP TINY — unrecoverable only.
34
+ HARDLINE_PATTERNS = [
35
+ # rm -r/-rf targeting the root filesystem (/ or /*)
36
+ [%r{\brm\s+(?:-\S*\s+)*(?:/|/\*)(?:\s|$)}, "recursive delete of root filesystem"],
37
+ # rm -r/-rf targeting a protected system directory
38
+ [%r{\brm\s+(?:-\S*\s+)*(?:/home|/root|/etc|/usr|/var|/bin|/sbin|/boot|/lib)(?:/\*)?(?:\s|$)},
39
+ "recursive delete of system directory"],
40
+ # rm targeting the home directory (~ or $HOME)
41
+ [%r{\brm\s+(?:-\S*\s+)*(?:~|\$home)(?:/?|/\*)?(?:\s|$)}, "recursive delete of home directory"],
42
+ # Filesystem format
43
+ [/\bmkfs(?:\.[a-z0-9]+)?\b/, "format filesystem (mkfs)"],
44
+ # dd to a raw block device
45
+ [%r{\bdd\b[^\n]*\bof=/dev/(?:sd|nvme|hd|mmcblk|vd|xvd|disk|loop)[a-z0-9]*}, "dd to raw block device"],
46
+ # Redirect to a raw block device (echo x > /dev/sda)
47
+ [%r{>\s*/dev/(?:sd|nvme|hd|mmcblk|vd|xvd|disk|loop)[a-z0-9]*\b}, "redirect to raw block device"],
48
+ # chmod/chown -R on the root filesystem
49
+ [%r{\b(?:chmod|chown)\s+(?:-\S*\s+)*-\S*r\S*\s+\S+\s+/(?:\s|$)}, "recursive chmod/chown of root filesystem"],
50
+ # Fork bomb (classic shell form, whitespace-tolerant)
51
+ [/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, "fork bomb"],
52
+ # Kill every process on the system
53
+ [/\bkill\s+(?:-\S+\s+)*-1\b/, "kill all processes"],
54
+ # System shutdown / reboot / halt / poweroff (anchored to cmd position)
55
+ [/#{CMDPOS}(?:shutdown|reboot|halt|poweroff)\b/, "system shutdown/reboot"],
56
+ [/#{CMDPOS}init\s+[06]\b/, "init 0/6 (shutdown/reboot)"],
57
+ [/#{CMDPOS}systemctl\s+(?:poweroff|reboot|halt|kexec)\b/, "systemctl poweroff/reboot"],
58
+ [/#{CMDPOS}telinit\s+[06]\b/, "telinit 0/6 (shutdown/reboot)"]
59
+ ].freeze
60
+
61
+ # sudo -S without a configured SUDO_PASSWORD is the model piping a
62
+ # *guessed* password via stdin — a brute-force vector. Unconditional
63
+ # block. Mirrors approval.py:_check_sudo_stdin_guard (:255).
64
+ SUDO_STDIN_RE = /(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+-s\b/
65
+
66
+ module_function
67
+
68
+ # Returns [true, description] when the command hits the hardline floor
69
+ # (a HARDLINE_PATTERN or the sudo-stdin guard), else [false, nil].
70
+ def detect(command)
71
+ normalized = normalize(command)
72
+ HARDLINE_PATTERNS.each do |regex, description|
73
+ return [true, description] if normalized.match?(regex)
74
+ end
75
+ return [true, "sudo password guessing via stdin (sudo -S)"] if sudo_stdin?(normalized)
76
+
77
+ [false, nil]
78
+ end
79
+
80
+ # Convenience predicate for the post-approval defense-in-depth check in
81
+ # ShellTool. Returns the description, or nil when the command is clear.
82
+ def block_reason(command)
83
+ blocked, description = detect(command)
84
+ blocked ? description : nil
85
+ end
86
+
87
+ # sudo -S only fires the guard when no SUDO_PASSWORD is configured —
88
+ # with one set, an internal transform legitimately injects -S elsewhere.
89
+ def sudo_stdin?(normalized)
90
+ return false if ENV.key?("SUDO_PASSWORD")
91
+
92
+ normalized.match?(SUDO_STDIN_RE)
93
+ end
94
+
95
+ # Minimal normalization: collapse runs of spaces/tabs (newlines kept so
96
+ # the command-separator anchors still fire), trim, and lowercase so
97
+ # trivial obfuscation (extra spaces, case) doesn't slip through.
98
+ # Deliberately NOT a full ANSI/Unicode normalizer — over-engineering for
99
+ # the hardline floor.
100
+ def normalize(command)
101
+ command.to_s.gsub(/[ \t]+/, " ").strip.downcase
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Pattern-based permission matcher supporting wildcards.
6
+ # Matches tool names, commands, and file paths against configured rules.
7
+ #
8
+ # Rules format in config:
9
+ # permissions:
10
+ # "git *": "allow"
11
+ # "shell rm -rf *": "deny"
12
+ # "file_system write ~/.env": "deny"
13
+ # "shell bundle *": "allow"
14
+ #
15
+ # Actions: "allow", "ask", "deny"
16
+ class PatternMatcher
17
+ ACTIONS = %w[allow ask deny].freeze
18
+
19
+ def initialize(rules: {})
20
+ @rules = parse_rules(rules)
21
+ end
22
+
23
+ # Returns the action for a given tool call description
24
+ # Returns :allow, :ask, or :deny
25
+ def match(tool_name, command_or_args = nil)
26
+ full_string = [tool_name, command_or_args].compact.join(" ")
27
+
28
+ # Check rules from most specific to least specific
29
+ @rules.each do |pattern, action|
30
+ return action.to_sym if matches_pattern?(full_string, pattern)
31
+ end
32
+
33
+ # Default: no explicit match
34
+ nil
35
+ end
36
+
37
+ # Returns true if the pattern matches the input
38
+ def matches_pattern?(input, pattern)
39
+ # Convert glob-style pattern to regex
40
+ regex_str = Regexp.escape(pattern)
41
+ .gsub('\*', ".*")
42
+ .gsub('\?', ".")
43
+ regex = Regexp.new("\\A#{regex_str}\\z", Regexp::IGNORECASE)
44
+ input.match?(regex)
45
+ end
46
+
47
+ private
48
+
49
+ def parse_rules(rules)
50
+ return {} unless rules.is_a?(Hash)
51
+
52
+ # Sort by specificity: more specific patterns first
53
+ # (longer patterns without wildcards are more specific)
54
+ rules.sort_by do |pattern, _|
55
+ specificity = pattern.length
56
+ specificity -= 10 if pattern.include?("*")
57
+ -specificity
58
+ end.to_h
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Derives the REUSABLE rule that an approval should be remembered as,
6
+ # instead of pinning memory to the exact "<tool>:<command>" string.
7
+ #
8
+ # Mirrors the reference persistence unit: approve_session/is_approved key on a
9
+ # PATTERN KEY, not the raw command.
10
+ # For a dangerous command the pattern key IS the dangerous description, so
11
+ # approving it once covers the whole risk class for the session. For a plain
12
+ # command the reference allowlist is prefix-ish; we derive a leading-token prefix
13
+ # the same way CommandAllowlist matches (start_with?, command_allowlist.rb).
14
+ #
15
+ # Pure derivation — no I/O, no persistence. Returns a small immutable Rule.
16
+ #
17
+ # kind == :pattern -> remember a dangerous-pattern CLASS (value = key)
18
+ # kind == :prefix -> remember a command PREFIX (value = "git")
19
+ # kind == :command -> remember one EXACT command (value = "git status")
20
+ module PrefixDeriver
21
+ Rule = Struct.new(:kind, :value, keyword_init: true) do
22
+ # Does this remembered rule cover `command`? Matching mirrors the
23
+ # storage shape: a pattern covers any sibling of its class, a prefix
24
+ # covers any command that start_with? it (like CommandAllowlist), an
25
+ # exact command covers only itself.
26
+ def covers?(command)
27
+ cmd = command.to_s
28
+ case kind
29
+ when :pattern then DangerousPatterns.detect(cmd)[1] == value
30
+ when :prefix then cmd.strip.start_with?(value.to_s.strip)
31
+ else cmd.strip == value.to_s.strip
32
+ end
33
+ end
34
+ end
35
+
36
+ # Wrapper commands whose first sub-token is part of the meaningful
37
+ # prefix ("bundle exec" / "npm run"), not the argument. Without this a
38
+ # naive "first token" prefix would collapse `bundle exec rspec` and
39
+ # `bundle install` into the same `bundle` rule.
40
+ WRAPPERS = {
41
+ "bundle" => %w[exec].freeze,
42
+ "npm" => %w[run].freeze,
43
+ "yarn" => %w[run].freeze,
44
+ "pnpm" => %w[run].freeze,
45
+ "rake" => [].freeze,
46
+ "cargo" => %w[run].freeze
47
+ }.freeze
48
+
49
+ module_function
50
+
51
+ # Builds the rule a (tool, command) approval should be remembered as.
52
+ #
53
+ # @param pattern_key [String, nil] the dangerous description when the
54
+ # caller has already detected one; we re-detect when absent so callers
55
+ # that only have the raw command still get a :pattern rule.
56
+ def rule_for(tool:, command:, pattern_key: nil)
57
+ cmd = command.to_s
58
+ key = pattern_key || DangerousPatterns.detect(cmd)[1]
59
+ return Rule.new(kind: :pattern, value: key) if key
60
+
61
+ # The :prefix rule ("allow `<head>` commands") only makes sense for the
62
+ # shell tool, where sibling commands genuinely share a leading
63
+ # executable (git status / git diff). For structured-arg tools the
64
+ # "command" is a file path (write/edit/read) or a code/arg fragment
65
+ # (ruby), so a derived prefix is nonsense — "allow `output.txt`
66
+ # commands", "allow `6` commands". Remember those by exact command
67
+ # instead, so the CLI/web offer no bogus prefix choice. (B6)
68
+ return command_rule(tool: tool, command: cmd) unless tool.to_s == "shell"
69
+
70
+ prefix = command_prefix(cmd)
71
+ return command_rule(tool: tool, command: cmd) if prefix.empty?
72
+
73
+ Rule.new(kind: :prefix, value: prefix)
74
+ end
75
+
76
+ # The NARROW rule used by :session / :always_command for S3 so behavior
77
+ # stays stable: a dangerous command remembers its pattern class (matching
78
+ # the reference), everything else remembers the exact command. The broad :prefix
79
+ # rule is derivable via rule_for but only wired into a decision in S5.
80
+ def narrow_rule_for(tool:, command:, pattern_key: nil)
81
+ cmd = command.to_s
82
+ key = pattern_key || DangerousPatterns.detect(cmd)[1]
83
+ return Rule.new(kind: :pattern, value: key) if key
84
+
85
+ command_rule(tool: tool, command: cmd)
86
+ end
87
+
88
+ def command_rule(tool:, command:)
89
+ value = command.to_s.strip
90
+ value = tool.to_s if value.empty?
91
+ Rule.new(kind: :command, value: value)
92
+ end
93
+
94
+ # Leading safe-token run of a plain command:
95
+ # "git status" -> "git"
96
+ # "bundle exec rspec" -> "bundle exec"
97
+ # "npm run test --watch" -> "npm run"
98
+ # A plain command keeps only its head; a wrapper command (bundle/npm/...)
99
+ # additionally keeps its declared verb (exec/run) so distinct wrapped
100
+ # tools don't collapse into one rule. The run stops at the first flag or
101
+ # argument-shaped token, mirroring CommandAllowlist's start_with? match.
102
+ def command_prefix(command)
103
+ tokens = command.to_s.strip.split(/\s+/)
104
+ return "" if tokens.empty?
105
+
106
+ head = tokens.first
107
+ prefix = [head]
108
+
109
+ if WRAPPERS.key?(head)
110
+ verb = tokens[1]
111
+ prefix << verb if verb && plain_word?(verb) && WRAPPERS[head].include?(verb)
112
+ end
113
+
114
+ prefix.join(" ")
115
+ end
116
+
117
+ # A "plain word" is a bare token: not a flag, no path/assignment/glob
118
+ # punctuation — the shape that can safely extend a prefix.
119
+ def plain_word?(token)
120
+ token.match?(/\A[A-Za-z0-9_:.-]+\z/) && !token.start_with?("-")
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Rubino
6
+ module Security
7
+ # Built-in auto-allow layer for provably READ-ONLY shell commands.
8
+ #
9
+ # Sits at the same decision step as the user command allowlist
10
+ # (ApprovalPolicy step 6) — BELOW the hardline floor and permissions:deny,
11
+ # which always run first, and ABOVE the confirm-policy prompt. A command
12
+ # auto-allows ONLY when the ENTIRE line parses as safe:
13
+ #
14
+ # - every chain segment (split on |, &&, ||, ;, newline) starts with a
15
+ # command from the read-only set (or approvals.readonly_commands);
16
+ # - no output redirection (>, >>, 2>; `tee` is simply not in the set),
17
+ # no command substitution ($(...) or backticks, live contexts only —
18
+ # single-quoted text is literal and stays allowed), no process
19
+ # substitution (<(...), >(...)), no backgrounding (&);
20
+ # - no leading variable assignments (FOO=bar cmd → prompt);
21
+ # - no mutating flags on otherwise-safe heads (find -exec/-delete/...,
22
+ # date -s, tree -o, git --output);
23
+ # - git only with a read-only subcommand, conservatively flag-checked;
24
+ # - no DangerousPatterns match on the whole line (defense-in-depth for
25
+ # user-extended sets).
26
+ #
27
+ # Anything the scanner cannot prove safe FAILS CLOSED to the normal
28
+ # approval prompt — never to silent execution. Pure functions, no I/O.
29
+ module ReadonlyCommands
30
+ # Read-only command heads auto-allowed by default. Conservative: each
31
+ # entry must be side-effect-free for ANY argument list once the flag
32
+ # checks below pass. `git` is handled separately (per-subcommand).
33
+ SAFE_COMMANDS = %w[
34
+ ls pwd find cat head tail grep rg wc file stat du df which
35
+ whoami date tree echo
36
+ ].freeze
37
+
38
+ # git subcommands that never mutate the repository. `remote` is
39
+ # restricted further below (bare or -v only — `git remote add` mutates),
40
+ # `branch` to pure-flag listing forms (`git branch foo` CREATES a branch).
41
+ GIT_READONLY_SUBCOMMANDS = %w[status log diff show rev-parse blame].freeze
42
+ GIT_BRANCH_READONLY_FLAGS = %w[
43
+ -a -r -v -vv --list --all --remotes --show-current --verbose
44
+ --merged --no-merged --color --no-color
45
+ ].freeze
46
+
47
+ # Mutating/executing flags that disqualify an otherwise-safe head.
48
+ # Matched as exact token or `flag=value`.
49
+ FORBIDDEN_FLAGS = {
50
+ "find" => %w[-exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls],
51
+ "date" => %w[-s --set],
52
+ "tree" => %w[-o]
53
+ }.freeze
54
+
55
+ # Leading `FOO=bar cmd` environment assignment — rejected, not stripped:
56
+ # an assignment can change what the command resolves to (PATH=...) or
57
+ # how it behaves, so it is never "provably read-only".
58
+ ASSIGNMENT_RE = /\A[A-Za-z_][A-Za-z0-9_]*=/
59
+
60
+ module_function
61
+
62
+ # True when the ENTIRE command line is provably read-only. `extra` is
63
+ # the approvals.readonly_commands config: command names or leading-token
64
+ # prefixes ("jq", "docker ps") merged into the built-in set.
65
+ def auto_allowed?(command, extra: [])
66
+ return false if DangerousPatterns.dangerous?(command)
67
+
68
+ segments = split_segments(command.to_s)
69
+ return false if segments.nil? || segments.empty?
70
+
71
+ segments.all? { |segment| safe_segment?(segment, extra: extra) }
72
+ end
73
+
74
+ # Splits a command line into chain segments (|, ||, &&, ;, newline),
75
+ # quote-aware. Returns nil — reject — on any construct that could smuggle
76
+ # a write or an execution: redirection (>), backgrounding (&), command
77
+ # substitution ($( or backtick in a live context), process substitution
78
+ # (<( / >( )), comments, trailing backslash, unterminated quotes. Plain
79
+ # `<` input redirection stays allowed. Single-quoted text is literal in
80
+ # POSIX shells, so substitutions inside it are safe to keep.
81
+ def split_segments(command)
82
+ segments = []
83
+ current = +""
84
+ i = 0
85
+ while i < command.length
86
+ char = command[i]
87
+ succ = command[i + 1]
88
+ case char
89
+ when "'", "\""
90
+ quoted = consume_quoted(command, i, char)
91
+ return nil unless quoted
92
+
93
+ current << quoted
94
+ i += quoted.length
95
+ next
96
+ when "\\"
97
+ return nil if succ.nil?
98
+
99
+ current << char << succ
100
+ i += 1
101
+ when "`", ">", "#"
102
+ return nil
103
+ when "$", "<"
104
+ return nil if succ == "("
105
+
106
+ current << char
107
+ when ";", "\n", "|", "&"
108
+ advance = flush_segment(char, succ, segments, current)
109
+ return nil unless advance
110
+
111
+ current = +""
112
+ i += advance
113
+ next
114
+ else
115
+ current << char
116
+ end
117
+ i += 1
118
+ end
119
+ segments << current
120
+ segments.map(&:strip).reject(&:empty?)
121
+ end
122
+
123
+ # Flushes the segment ended by a chain operator and returns how many
124
+ # characters the operator consumes (2 for && and ||, 1 otherwise), or
125
+ # nil for a lone & — backgrounding is never provably read-only.
126
+ def flush_segment(char, succ, segments, current)
127
+ return nil if char == "&" && succ != "&"
128
+
129
+ segments << current
130
+ "|&".include?(char) && succ == char ? 2 : 1
131
+ end
132
+
133
+ # Consumes the quoted region opening at `start`. Returns the full
134
+ # substring including both quotes, or nil when the quote is unterminated
135
+ # or — for double quotes, where substitutions stay LIVE — when it
136
+ # contains $( or a backtick. Single-quoted text is literal in POSIX
137
+ # shells, so anything inside is safe to keep verbatim.
138
+ def consume_quoted(command, start, quote)
139
+ i = start + 1
140
+ while i < command.length
141
+ char = command[i]
142
+ if quote == "\""
143
+ return nil if char == "`" || (char == "$" && command[i + 1] == "(")
144
+
145
+ if char == "\\"
146
+ i += 2
147
+ next
148
+ end
149
+ end
150
+ return command[start..i] if char == quote
151
+
152
+ i += 1
153
+ end
154
+ nil
155
+ end
156
+
157
+ # One pipeline segment: tokenize (Shellwords — a parse error rejects),
158
+ # refuse leading assignments, then require the head to be a safe command
159
+ # whose flags pass the per-command checks, or an `extra` config entry.
160
+ def safe_segment?(segment, extra: [])
161
+ tokens = Shellwords.split(segment)
162
+ return false if tokens.empty? || tokens.first.match?(ASSIGNMENT_RE)
163
+
164
+ head = tokens.first
165
+ return safe_git?(tokens) if head == "git"
166
+ return safe_flags?(head, tokens) if SAFE_COMMANDS.include?(head)
167
+
168
+ extra_match?(tokens, extra)
169
+ rescue ArgumentError
170
+ false # unbalanced quotes etc. — fall through to the prompt
171
+ end
172
+
173
+ def safe_flags?(head, tokens)
174
+ forbidden = FORBIDDEN_FLAGS[head]
175
+ return true unless forbidden
176
+
177
+ tokens.drop(1).none? do |token|
178
+ forbidden.any? { |flag| token == flag || token.start_with?("#{flag}=") }
179
+ end
180
+ end
181
+
182
+ # Read-only git: a safe subcommand (no global flags before it — `git -C`
183
+ # falls to the prompt), never --output (git log/diff/show can write a
184
+ # file with it), branch/remote in their pure listing forms only.
185
+ def safe_git?(tokens)
186
+ sub = tokens[1]
187
+ return false if sub.nil? || sub.start_with?("-")
188
+
189
+ rest = tokens.drop(2)
190
+ return false if rest.any? { |t| t == "--output" || t.start_with?("--output=") }
191
+
192
+ case sub
193
+ when *GIT_READONLY_SUBCOMMANDS then true
194
+ when "branch" then rest.all? { |t| GIT_BRANCH_READONLY_FLAGS.include?(t) }
195
+ when "remote" then rest.empty? || rest == ["-v"] || rest == ["--verbose"]
196
+ else false
197
+ end
198
+ end
199
+
200
+ # approvals.readonly_commands entries extend the built-in set: a bare
201
+ # name ("jq") matches that head, a multi-word entry ("docker ps")
202
+ # matches those leading tokens exactly.
203
+ def extra_match?(tokens, extra)
204
+ Array(extra).any? do |entry|
205
+ entry_tokens = entry.to_s.strip.split(/\s+/)
206
+ !entry_tokens.empty? && tokens.first(entry_tokens.length) == entry_tokens
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Rubino
7
+ module Session
8
+ # Serializes one session's transcript to clean markdown — the `/export`
9
+ # backend. Deliberately minimal: user/assistant turns verbatim, tool
10
+ # calls and tool results as one-liners, system rows (prompt scaffolding,
11
+ # compaction summaries) omitted. Reasoning never reaches the message
12
+ # store, so a transcript export is reasoning-free by construction.
13
+ class Exporter
14
+ # Tool-call arguments are context, not payload — clamp the one-liner.
15
+ ARGS_PREVIEW_CHARS = 120
16
+
17
+ def initialize(session, store: Store.new)
18
+ @session = session
19
+ @store = store
20
+ end
21
+
22
+ # The full markdown document for the session.
23
+ def markdown
24
+ lines = header
25
+ @store.for_session(@session[:id]).each do |msg|
26
+ lines.concat(render(msg))
27
+ end
28
+ "#{lines.join("\n")}\n"
29
+ end
30
+
31
+ # Writes #markdown to +path+ (default ./rubino-session-<id8>.md in the
32
+ # current directory) and returns the absolute path written.
33
+ def write(path = nil)
34
+ target = File.expand_path(path.to_s.empty? ? default_filename : path.to_s)
35
+ File.write(target, markdown)
36
+ target
37
+ end
38
+
39
+ def default_filename
40
+ "rubino-session-#{@session[:id].to_s[0, 8]}.md"
41
+ end
42
+
43
+ private
44
+
45
+ def header
46
+ meta = ["- session: #{@session[:id]}"]
47
+ meta << "- title: #{@session[:title]}" if @session[:title]
48
+ meta << "- model: #{@session[:model]}" if @session[:model]
49
+ meta << "- exported: #{Time.now.utc.iso8601}"
50
+ ["# rubino session #{@session[:id].to_s[0, 8]}", "", *meta, ""]
51
+ end
52
+
53
+ def render(msg)
54
+ case msg.role.to_s
55
+ when "user" then ["## User", "", msg.content.to_s, ""]
56
+ when "assistant" then render_assistant(msg)
57
+ when "tool" then render_tool(msg)
58
+ else []
59
+ end
60
+ end
61
+
62
+ # The assistant turn's visible text. The tool-call one-liner is emitted by
63
+ # #render_tool from the `tool`-role result row (which carries `tool_name` +
64
+ # `arguments` on BOTH the streaming and non-streaming paths), NOT here from
65
+ # the assistant row's `tool_calls` metadata — that metadata is absent on the
66
+ # streaming path (the default; the call is emitted mid-stream), so reading
67
+ # it left the call one-liner dead in every real export (#216). A pure
68
+ # tool-call turn has no visible text, so this renders just the heading.
69
+ def render_assistant(msg)
70
+ lines = ["## Assistant", ""]
71
+ text = msg.content.to_s
72
+ lines.push(text, "") unless text.empty?
73
+ lines
74
+ end
75
+
76
+ # A `tool`-role row renders its call one-liner (reconstructed from the
77
+ # row's own `tool_name` + `arguments` metadata) followed by its result
78
+ # one-liner. The call line is the only place the command/args survive on
79
+ # the streaming path, where the assistant turn persists without
80
+ # `tool_calls` (#216).
81
+ def render_tool(msg)
82
+ name = msg.tool_name || "tool"
83
+ args = msg.metadata.is_a?(Hash) ? msg.metadata[:arguments] : nil
84
+ [
85
+ "- tool call: `#{name}` #{args_preview(args)}".rstrip, "",
86
+ "- tool result: `#{name}` (#{msg.content.to_s.length} chars)", ""
87
+ ]
88
+ end
89
+
90
+ def args_preview(arguments)
91
+ return "" if arguments.nil? || arguments == {}
92
+
93
+ text = arguments.is_a?(String) ? arguments : JSON.generate(arguments)
94
+ text = "#{text[0, ARGS_PREVIEW_CHARS]}…" if text.length > ARGS_PREVIEW_CHARS
95
+ "`#{text}`"
96
+ rescue StandardError
97
+ ""
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Rubino
7
+ module Session
8
+ # Handles message persistence within a session.
9
+ # Messages include user input, assistant responses, tool calls and results.
10
+ class Message
11
+ VALID_ROLES = %w[system user assistant tool].freeze
12
+
13
+ attr_reader :id, :session_id, :role, :content, :tool_name,
14
+ :tool_call_id, :token_count, :metadata, :created_at
15
+
16
+ def initialize(attrs = {})
17
+ @id = attrs[:id] || SecureRandom.uuid
18
+ @session_id = attrs[:session_id]
19
+ @role = attrs[:role]
20
+ @content = attrs[:content]
21
+ @tool_name = attrs[:tool_name]
22
+ @tool_call_id = attrs[:tool_call_id]
23
+ @token_count = attrs[:token_count] || 0
24
+ @metadata = attrs[:metadata] || {}
25
+ @created_at = attrs[:created_at] || Time.now.utc.iso8601
26
+ end
27
+
28
+ # Validates the message attributes
29
+ def valid?
30
+ VALID_ROLES.include?(@role) && @session_id
31
+ end
32
+
33
+ # Returns a hash suitable for database insertion
34
+ def to_row
35
+ {
36
+ id: @id,
37
+ session_id: @session_id,
38
+ role: @role,
39
+ content: @content,
40
+ tool_name: @tool_name,
41
+ tool_call_id: @tool_call_id,
42
+ token_count: @token_count,
43
+ metadata_json: @metadata.empty? ? nil : JSON.generate(@metadata),
44
+ created_at: @created_at
45
+ }
46
+ end
47
+
48
+ # Returns a hash for LLM context building. A user message that collapsed a
49
+ # large paste keeps the compact "[Pasted text #N …]" placeholder in its
50
+ # stored/displayed content (#213); here we expand each placeholder back to
51
+ # its full body for the model, so the provider sees everything while the
52
+ # transcript echo (live AND on resume) stays clean.
53
+ def to_context
54
+ msg = { role: @role, content: expand_pastes(@content) }
55
+ msg[:tool_call_id] = @tool_call_id if @tool_call_id
56
+ msg[:name] = @tool_name if @tool_name
57
+ # Surface assistant tool_calls (persisted as metadata) so the adapter
58
+ # can rebuild the toolUse block expected by strict providers on resume.
59
+ msg[:tool_calls] = @metadata[:tool_calls] if @metadata.is_a?(Hash) && @metadata[:tool_calls]
60
+ msg
61
+ end
62
+
63
+ private
64
+
65
+ # Substitutes each stored [token, body] paste expansion back into +text+.
66
+ # The pairs are stored as an array (not a hash) so the placeholder tokens
67
+ # survive the metadata JSON round-trip without being mangled into symbols.
68
+ def expand_pastes(text)
69
+ return text unless text.is_a?(String) && @metadata.is_a?(Hash)
70
+
71
+ Array(@metadata[:paste_expansions]).reduce(text) do |acc, (token, body)|
72
+ token && body ? acc.gsub(token, body) : acc
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end