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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Run
5
+ # Remembers approval decisions that should survive past the current
6
+ # call so the agent doesn't re-prompt the user for the same operation
7
+ # in the same session.
8
+ #
9
+ # Granularity: a decision is stored as a DERIVED RULE, not the exact
10
+ # "<tool>:<command>" string. The caller still passes a scope shaped like
11
+ # "shell:rm -rf /tmp/cache" or "write:report.md"; the cache splits it into
12
+ # (tool, command) and asks Security::PrefixDeriver for the rule to remember.
13
+ # This mirrors the reference, which keys session approvals on a PATTERN KEY rather
14
+ # than the raw command (approve_session / is_approved). The practical effect for S3:
15
+ # - a DANGEROUS command remembers its pattern CLASS, so approving e.g.
16
+ # `git push --force origin main` once also covers `git push -f other`
17
+ # in the same session (same "git force push" class);
18
+ # - a PLAIN command still remembers only the exact command, so approving
19
+ # `git status` does NOT auto-approve `git diff` (narrow for S3; the
20
+ # broad prefix rule is derived but wired into a decision only in S5).
21
+ #
22
+ # A scope with no ":" (a tool-wide scope like "shell") has no command to
23
+ # derive from and is stored/matched verbatim.
24
+ #
25
+ # Persistence: in-memory, process-lifetime. "session" decisions die with
26
+ # the process; "always" would deserve disk persistence but isn't wired up
27
+ # yet (S5), so we treat both as session-scoped for now.
28
+ #
29
+ # Thread-safe: every read/write goes through @mutex.
30
+ class SessionApprovalCache
31
+ # Singleton accessor. We don't use Dry::Container or similar here
32
+ # because the cache is process-global state that the runner needs
33
+ # to inject into per-run UI::API instances; one shared object is
34
+ # the simplest expression of "remember across runs of the same
35
+ # session".
36
+ def self.instance
37
+ @instance ||= new
38
+ end
39
+
40
+ # Resets the singleton — used by tests that need a clean slate.
41
+ # Avoids hidden cross-test leakage when specs share the process.
42
+ def self.reset_singleton!
43
+ @instance = nil
44
+ end
45
+
46
+ # Decisions that should be persisted on approval.
47
+ REMEMBERED_DECISIONS = %w[session always].freeze
48
+
49
+ def initialize
50
+ @data = Hash.new { |h, k| h[k] = [] } # session_id => [Rule, ...]
51
+ @mutex = Mutex.new
52
+ end
53
+
54
+ # Records a decision for (session_id, scope) as a derived rule. No-op
55
+ # when either value is blank, or the decision isn't a remembered kind.
56
+ def remember(session_id, scope, decision)
57
+ return unless session_id && scope
58
+ return unless REMEMBERED_DECISIONS.include?(decision.to_s.downcase)
59
+
60
+ rule = rule_for_scope(scope)
61
+ @mutex.synchronize do
62
+ rules = @data[session_id.to_s]
63
+ rules << rule unless rules.any? { |r| r == rule }
64
+ end
65
+ end
66
+
67
+ # True when a prior decision for this session already covers the command
68
+ # carried by `scope` — pattern-class membership, prefix start_with?, or an
69
+ # exact-command match, per the stored rule kinds.
70
+ def allowed?(session_id, scope)
71
+ return false unless session_id && scope
72
+
73
+ command = scope_command(scope)
74
+ @mutex.synchronize do
75
+ @data[session_id.to_s].any? { |rule| rule.covers?(command) }
76
+ end
77
+ end
78
+
79
+ # Drops every cached decision for one session (e.g. after a
80
+ # session is deleted). Pass nil to wipe every session.
81
+ def forget!(session_id = nil)
82
+ @mutex.synchronize do
83
+ if session_id
84
+ @data.delete(session_id.to_s)
85
+ else
86
+ @data.clear
87
+ end
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # Splits a "<tool>:<command>" scope into the rule to remember. A scope
94
+ # without a ":" is tool-wide (no command) — remember it verbatim as an
95
+ # exact rule so the tool-scope short-circuit in UI::CLI keeps working.
96
+ def rule_for_scope(scope)
97
+ tool, command = split_scope(scope)
98
+ return Security::PrefixDeriver::Rule.new(kind: :command, value: scope.to_s) if command.nil?
99
+
100
+ Security::PrefixDeriver.narrow_rule_for(tool: tool, command: command)
101
+ end
102
+
103
+ # The command a query scope refers to: the part after the first ":",
104
+ # or the whole scope when there is none (tool-wide scope matched verbatim).
105
+ def scope_command(scope)
106
+ _tool, command = split_scope(scope)
107
+ command.nil? ? scope.to_s : command
108
+ end
109
+
110
+ def split_scope(scope)
111
+ str = scope.to_s
112
+ return [str, nil] unless str.include?(":")
113
+
114
+ str.split(":", 2)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Persists an approved rule value to `security.command_allowlist` so it
6
+ # survives a process restart and pre-approves future sibling commands
7
+ # through the existing CommandAllowlist (prefix start_with?) path.
8
+ #
9
+ # Mirrors the reference save_permanent_allowlist,
10
+ # which writes pattern keys to `command_allowlist` in config.yaml, and the
11
+ # resolve-time persistence on the gateway path (:1342-1351).
12
+ #
13
+ # Append-unique: an already-listed value is a no-op (no duplicate rows, no
14
+ # rewrite). The write goes through Config::Writer (dot-notation -> YAML) and
15
+ # ALSO updates the live Rubino.configuration so a CommandAllowlist built
16
+ # in the same process immediately sees the new prefix without a reload.
17
+ #
18
+ # SCOPING NOTE: Config::Writer writes the process-global config.yml. This
19
+ # assumes a single-process / single-home deployment, so process-global ==
20
+ # per-user here — acceptable. For any SHARED-server deployment, `always_*`
21
+ # persistence would need per-user config scoping (or web `always` treated as
22
+ # session-only); do NOT rely on this writer as-is in a multi-user process.
23
+ module AllowlistPersister
24
+ KEY = "security.command_allowlist"
25
+
26
+ module_function
27
+
28
+ # Appends `value` to security.command_allowlist (unique). Returns the
29
+ # resulting allowlist array. A blank value is a no-op.
30
+ def persist(value, config: nil, config_path: nil)
31
+ rule_value = value.to_s.strip
32
+ return current_allowlist(config) if rule_value.empty?
33
+
34
+ config ||= Rubino.configuration
35
+ existing = current_allowlist(config)
36
+ return existing if existing.include?(rule_value)
37
+
38
+ updated = existing + [rule_value]
39
+ Config::Writer.new(config_path: config_path || default_config_path).set(KEY, updated)
40
+ # Keep the live config in sync so a CommandAllowlist built this process
41
+ # sees the new prefix immediately (the writer only touches disk).
42
+ config.set("security", "command_allowlist", updated)
43
+ updated
44
+ end
45
+
46
+ def current_allowlist(config)
47
+ (config || Rubino.configuration).security_command_allowlist.dup
48
+ end
49
+
50
+ def default_config_path
51
+ Config::Loader.new.config_path
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Determines whether a tool execution requires user approval.
6
+ # Uses pattern-based rules, tool risk levels, and doom loop detection.
7
+ #
8
+ # Config example:
9
+ # approvals:
10
+ # mode: "manual" # manual | auto | skip
11
+ # permissions:
12
+ # "git *": "allow"
13
+ # "shell rm *": "deny"
14
+ # "shell bundle *": "allow"
15
+ # "file_system write ~/.env": "deny"
16
+ class ApprovalPolicy
17
+ MODES = %w[manual auto skip].freeze
18
+
19
+ # Why the most recent #decide returned :deny — :hardline (the
20
+ # non-bypassable floor), :permission_rule (an explicit permissions deny
21
+ # rule), or :doom_loop (the repeated-identical-call guard). nil when the
22
+ # last decision wasn't a deny. ToolExecutor reads this right after
23
+ # #decide to build a reason-specific model-facing denial message, so a
24
+ # policy denial is never reported as "denied by user" (#143).
25
+ attr_reader :last_deny_reason
26
+
27
+ def initialize(config: nil, agent_overrides: nil)
28
+ @config = config || Rubino.configuration
29
+ @mode = @config.approvals_mode
30
+ # Effective shell prompt policy (:confirm_all | :dangerous_only).
31
+ # Derived from security.confirm_policy, with security.require_confirmation_for_shell
32
+ # as a back-compat alias (see Configuration#confirm_policy). Older config
33
+ # objects that predate the accessor fall back to :confirm_all.
34
+ @confirm_policy =
35
+ @config.respond_to?(:confirm_policy) ? @config.confirm_policy : :confirm_all
36
+ @pattern_matcher = PatternMatcher.new(
37
+ rules: load_permission_rules(agent_overrides)
38
+ )
39
+ @doom_detector = DoomLoopDetector.new
40
+ end
41
+
42
+ # Returns the decision for a tool call: :allow, :ask, :deny
43
+ #
44
+ # CANONICAL DECISION ORDER (deny-class checks precede every allow path).
45
+ # Mirrors the reconciled reference ordering:
46
+ #
47
+ # 1. hardline(:deny) non-bypassable floor BELOW yolo
48
+ # 2. permissions:deny an explicit deny rule also beats yolo
49
+ # 3. yolo / skip-approvals allow-exit (doom still guards it)
50
+ # 4. doom loop break a stuck autopilot
51
+ # 5. permissions:allow / :ask remaining explicit rules
52
+ # 6. command_allowlist (prefix) pre-approved commands -> :allow
53
+ # 6b. readonly auto-allow parse-validated read-only shell -> :allow
54
+ # 7-8. confirm_policy shell gate confirm_all -> :ask; dangerous_only
55
+ # -> :ask only if dangerous?, else :allow
56
+ # 9. mode fallback
57
+ #
58
+ # The invariant that makes this slice worth doing: HARDLINE and an
59
+ # explicit permissions:deny BOTH run before any allow path (yolo,
60
+ # permissions:allow, command_allowlist), so neither can be overridden
61
+ # by a fast-path the way yolo used to override deny rules.
62
+ def decide(tool, arguments: {})
63
+ @last_deny_reason = nil
64
+ command_str = self.class.command_string(tool, arguments)
65
+
66
+ # 1. Hardline floor — a floor BELOW yolo. Catastrophic, unrecoverable
67
+ # commands (rm -rf /, mkfs, dd to a raw device, fork bomb,
68
+ # shutdown/reboot, sudo -S password guessing) are denied
69
+ # UNCONDITIONALLY: before yolo/skip, before doom, before any
70
+ # permissions:allow rule or command_allowlist entry. Opting into
71
+ # yolo trusts the agent with your files, NOT to wipe the disk.
72
+ # Mirrors the reference approval module (enforced first).
73
+ blocked, = HardlineGuard.detect(command_str)
74
+ return deny_with(:hardline) if blocked
75
+
76
+ # 2. Explicit permissions:deny — like hardline, a deny rule is a
77
+ # deny-class check and must beat every allow path. We evaluate the
78
+ # pattern rules ONCE here and reuse the result below; only the :deny
79
+ # verdict short-circuits before yolo. allow/ask wait until after the
80
+ # yolo allow-exit and the doom guard (steps 3-4) so they keep their
81
+ # original precedence. Mirrors the deny-before-allow ordering in the
82
+ # plan (hardline -> permissions:deny -> yolo -> doom -> allow/ask).
83
+ pattern_result = @pattern_matcher.match(tool.name, command_str)
84
+ return deny_with(:permission_rule) if pattern_result == :deny
85
+
86
+ # 3. Modes.yolo short-circuits the remaining allow/ask logic. We still
87
+ # run the doom detector AFTER, because an autopilot stuck in a loop
88
+ # is the one thing yolo isn't supposed to license.
89
+ if Rubino::Modes.skip_approvals?
90
+ return deny_with(:doom_loop) if @doom_detector.record(tool_name: tool.name, arguments: arguments)
91
+
92
+ return :allow
93
+ end
94
+
95
+ # 4. Doom loop guard.
96
+ if @doom_detector.record(tool_name: tool.name, arguments: arguments)
97
+ return deny_with(:doom_loop) # Break the loop
98
+ end
99
+
100
+ # 5. Remaining explicit pattern rules (allow / ask). deny was already
101
+ # handled in step 2.
102
+ return pattern_result if pattern_result
103
+
104
+ # 6. Config allowlist of pre-approved commands. Checked AFTER deny
105
+ # patterns (deny always wins) but BEFORE mode-based decision so a
106
+ # listed command never triggers a manual prompt.
107
+ return :allow if command_pre_approved?(command_str)
108
+
109
+ # 6b. Built-in read-only auto-allow — the same allowlist seam as
110
+ # step 6, just with a parse-validated built-in set instead of
111
+ # user-configured prefixes. Runs BELOW the hardline floor (step 1)
112
+ # and permissions:deny (step 2), so the floor always wins even for
113
+ # commands added via approvals.readonly_commands. A line the
114
+ # validator cannot prove read-only falls through to the prompt.
115
+ return :allow if readonly_auto_allowed?(tool, command_str)
116
+
117
+ # 7-8. confirm_policy gate for a shell command not otherwise resolved.
118
+ # NOT under config "skip" (nor runtime yolo, handled at step 3) —
119
+ # those are the explicit operator overrides that mean "stop
120
+ # prompting me".
121
+ #
122
+ # confirm_all (DEFAULT, == legacy require_confirmation_for_shell:true)
123
+ # every such shell command -> :ask. shell is :high risk so manual
124
+ # mode would ask anyway; this also keeps it gated under auto mode.
125
+ #
126
+ # dangerous_only (reference-faithful, == legacy alias:false)
127
+ # prompt ONLY when the command matches a DangerousPattern
128
+ # (git push --force, curl|sh, recursive rm of a non-root path,
129
+ # ...). Safe commands run unprompted. Mirrors approval.py:475
130
+ # where detect_dangerous_command is the sole prompt trigger.
131
+ # The hardline floor (step 1) and permissions:deny (step 2) already
132
+ # ran, so dangerous_only NEVER weakens the non-bypassable floor.
133
+ if tool.name == "shell" && @mode != "skip"
134
+ case @confirm_policy
135
+ when :dangerous_only
136
+ return :ask if dangerous?(command_str)
137
+
138
+ return :allow
139
+ else # :confirm_all
140
+ return :ask
141
+ end
142
+ end
143
+
144
+ # 9. Fall back to mode-based decision
145
+ mode_based_decision(tool)
146
+ end
147
+
148
+ # True when a command matches a recoverable-but-risky DangerousPattern
149
+ # (distinct from the hardline floor). Computed signal for the structured
150
+ # ask context and for S4's dangerous_only confirm policy; #decide does
151
+ # not yet branch on it (see step 7). Mirrors detect_dangerous_command.
152
+ def dangerous?(command)
153
+ DangerousPatterns.dangerous?(command)
154
+ end
155
+
156
+ # Returns true if a specific command is pre-approved by the config
157
+ # allowlist. An empty allowlist pre-approves NOTHING.
158
+ def command_pre_approved?(command)
159
+ CommandAllowlist.new(config: @config).allowed?(command)
160
+ end
161
+
162
+ # True when the shell command is provably read-only and the
163
+ # approvals.auto_allow_readonly gate (default ON) is open. Shell-only:
164
+ # for every other tool the "command" is a path or argument fragment.
165
+ def readonly_auto_allowed?(tool, command)
166
+ return false unless tool.name == "shell"
167
+ return false unless @config.auto_allow_readonly?
168
+
169
+ ReadonlyCommands.auto_allowed?(command, extra: @config.approvals_readonly_commands)
170
+ end
171
+
172
+ # Builds the string representation of a tool call used both for
173
+ # pattern-rule matching here and for the UI's session-approval scope
174
+ # in ToolExecutor. One builder so the granularity stays identical:
175
+ # approving `shell ls` never auto-approves `shell rm -rf /`.
176
+ def self.command_string(tool, arguments)
177
+ args = arguments || {}
178
+ case tool.name
179
+ when "shell"
180
+ (args["command"] || args[:command]).to_s
181
+ when "read", "write", "edit", "multi_edit", "attach_file"
182
+ (args["file_path"] || args[:file_path]).to_s
183
+ when "shell_output", "shell_kill", "shell_input"
184
+ (args["run_id"] || args[:run_id]).to_s
185
+ else
186
+ args.values.first.to_s
187
+ end
188
+ end
189
+
190
+ # Resets doom loop detector (call on new user input)
191
+ def reset_turn!
192
+ @doom_detector.reset!
193
+ end
194
+
195
+ private
196
+
197
+ # Records WHY this deny fired before returning it (see #last_deny_reason).
198
+ def deny_with(reason)
199
+ @last_deny_reason = reason
200
+ :deny
201
+ end
202
+
203
+ def mode_based_decision(tool)
204
+ case @mode
205
+ when "skip"
206
+ :allow
207
+ when "auto"
208
+ tool.risk_level == :high ? :ask : :allow
209
+ when "manual"
210
+ tool.risky? ? :ask : :allow
211
+ else
212
+ tool.risky? ? :ask : :allow
213
+ end
214
+ end
215
+
216
+ def load_permission_rules(agent_overrides)
217
+ base_rules = @config.dig("permissions") || {}
218
+
219
+ if agent_overrides.is_a?(Hash)
220
+ base_rules.merge(agent_overrides)
221
+ else
222
+ base_rules
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Manages a whitelist of shell commands that can be executed without confirmation.
6
+ class CommandAllowlist
7
+ def initialize(config: nil)
8
+ @config = config || Rubino.configuration
9
+ @allowlist = @config.security_command_allowlist
10
+ end
11
+
12
+ # Returns true if the command matches an entry in the allowlist.
13
+ # An EMPTY allowlist matches NOTHING — pre-approval is opt-in, so an
14
+ # unconfigured allowlist must never auto-approve everything.
15
+ def allowed?(command)
16
+ return false if @allowlist.empty?
17
+
18
+ @allowlist.any? do |allowed|
19
+ command.strip.start_with?(allowed.strip)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Dangerous (recoverable-but-risky) command patterns — the layer ABOVE the
6
+ # hardline floor. These are operations that can lose work, rewrite shared
7
+ # history, escalate privilege, or touch system/credential files, but which
8
+ # a user might legitimately want to run with confirmation. Unlike
9
+ # HardlineGuard (catastrophic, no recovery path, never runs), a dangerous
10
+ # match is meant to drive an :ask — yolo/approval CAN pass it through.
11
+ #
12
+ # This is deliberately DISTINCT from HardlineGuard: there is NO overlap.
13
+ # Hardline owns "rm -rf /", "mkfs", "dd to /dev/sd*", shutdown/reboot,
14
+ # fork bomb, kill-all, sudo -S guessing. DangerousPatterns owns the
15
+ # recoverable cousins: recursive rm of NON-root paths, git force-push /
16
+ # reset --hard, curl|sh, broad chmod/chown, writes into /etc, sudo with
17
+ # privilege flags, find -delete, etc.
18
+ #
19
+ # Mirrors the reference approval module: DANGEROUS_PATTERNS and
20
+ # detect_dangerous_command. A faithful CORE subset of the reference ~47
21
+ # patterns — the important risk classes, not an exhaustive copy.
22
+ module DangerousPatterns
23
+ # Sensitive write targets (system config, block devices, ssh/credential
24
+ # files). Mirrors approval.py:_SENSITIVE_WRITE_TARGET (:152) in spirit,
25
+ # kept compact. /etc plus its macOS /private/etc mirror.
26
+ SYSTEM_CONFIG_PATH = %r{(?:/etc/|/private/(?:etc|var|tmp)/)}.source.freeze
27
+ SENSITIVE_WRITE_TARGET =
28
+ %r{(?:#{SYSTEM_CONFIG_PATH}|/dev/sd|(?:~|\$home)/\.ssh/|(?:~|\$home)/\.(?:netrc|pgpass|npmrc|pypirc)\b)}.source.freeze
29
+
30
+ # [regex, human description "pattern key"]. Matched against the
31
+ # lowercased, whitespace-normalized command. The description doubles as
32
+ # the persisted approval key in later slices (mirrors the reference pattern_key).
33
+ PATTERNS = [
34
+ # --- Recursive / forced delete of NON-root paths (root is hardline).
35
+ # -\S*r catches both -rf and the long --recursive form. ---
36
+ [/\brm\s+-\S*r/, "recursive delete"],
37
+
38
+ # --- Broad permission / ownership changes ---
39
+ [/\bchmod\s+(?:-\S*\s+)*(?:777|666|o\+[rwx]*w|a\+[rwx]*w)\b/, "world/other-writable permissions"],
40
+ [/\bchown\s+(?:-\S*)?r\s+root/, "recursive chown to root"],
41
+
42
+ # --- Privilege escalation: sudo with non-interactive privilege flags ---
43
+ # Plain `sudo cmd` is TTY-bound and excluded; these flags (stdin/
44
+ # askpass/shell/list) are the agent-reachable escalation forms.
45
+ # (sudo -S WITHOUT a configured password is hardline; this is the
46
+ # broader, recoverable privilege-flag class.)
47
+ [/\bsudo\b[^;|&\n]*?\s+(?:--stdin\b|-a\b|--askpass\b|-s\b)/,
48
+ "sudo with privilege flag (stdin/askpass/shell/list)"],
49
+
50
+ # --- Pipe remote content to a shell (curl|sh, wget|bash) ---
51
+ [%r{\b(?:curl|wget)\b.*\|\s*(?:[/\w]*/)?(?:ba)?sh(?:\s|$|-c)}, "pipe remote content to shell"],
52
+ [/\b(?:bash|sh|zsh|ksh)\s+<\s*<?\s*\(\s*(?:curl|wget)\b/, "execute remote script via process substitution"],
53
+
54
+ # --- Write / overwrite into system or credential files ---
55
+ [/>>?\s*["']?#{SENSITIVE_WRITE_TARGET}/, "overwrite system file via redirection"],
56
+ [/\btee\b.*["']?#{SENSITIVE_WRITE_TARGET}/, "overwrite system file via tee"],
57
+ [/\b(?:cp|mv|install)\b.*\s#{SYSTEM_CONFIG_PATH}/, "copy/move file into system config path"],
58
+ [/\bsed\s+-\S*i.*\s#{SYSTEM_CONFIG_PATH}/, "in-place edit of system config"],
59
+
60
+ # --- Service control ---
61
+ [/\bsystemctl\s+(?:-\S+\s+)*(?:stop|restart|disable|mask)\b/, "stop/restart system service"],
62
+
63
+ # --- Force-kill process sweeps (kill-all -1 is hardline) ---
64
+ [/\bpkill\s+-9\b/, "force kill processes"],
65
+ [/\bkillall\s+(?:-\S*\s+)*-(?:9|kill|sigkill)\b/, "force kill processes (killall -KILL)"],
66
+ [/\bkillall\s+(?:-\S*\s+)*-r\b/, "kill processes by regex (killall -r)"],
67
+
68
+ # --- find that deletes ---
69
+ [%r{\bfind\b.*-exec(?:dir)?\s+(?:/\S*/)?rm\b}, "find -exec/-execdir rm"],
70
+ [/\bfind\b.*-delete\b/, "find -delete"],
71
+ [/\bxargs\s+.*\brm\b/, "xargs with rm"],
72
+
73
+ # --- Git destructive / history-rewriting operations ---
74
+ [/\bgit\s+reset\s+--hard\b/, "git reset --hard (destroys uncommitted changes)"],
75
+ [/\bgit\s+push\b.*--force\b/, "git force push (rewrites remote history)"],
76
+ [/\bgit\s+push\b.*\s-f\b/, "git force push short flag (rewrites remote history)"],
77
+ [/\bgit\s+clean\s+-\S*f/, "git clean with force (deletes untracked files)"],
78
+ [/\bgit\s+branch\s+-d\b/, "git branch force delete"],
79
+
80
+ # --- Filesystem format / raw disk copy (the recoverable framings;
81
+ # mkfs and dd-to-/dev/sd* themselves are hardline) ---
82
+ [/\bdd\s+.*if=/, "disk copy"],
83
+
84
+ # --- Destructive SQL ---
85
+ [/\bdrop\s+(?:table|database)\b/, "SQL DROP"],
86
+ [/\bdelete\s+from\b(?![^\n]*\bwhere\b)/, "SQL DELETE without WHERE"],
87
+ [/\btruncate\s+(?:table)?\s*\w/, "SQL TRUNCATE"]
88
+ ].freeze
89
+
90
+ module_function
91
+
92
+ # Returns [true, pattern_key, description] when the command matches a
93
+ # dangerous pattern, else [false, nil, nil]. The pattern_key and
94
+ # description are the same string (the human-readable key) — the tuple
95
+ # arity mirrors the reference detect_dangerous_command so later slices
96
+ # can persist the key.
97
+ def detect(command)
98
+ normalized = normalize(command)
99
+ PATTERNS.each do |regex, description|
100
+ return [true, description, description] if normalized.match?(regex)
101
+ end
102
+ [false, nil, nil]
103
+ end
104
+
105
+ # Convenience predicate: true when the command hits a dangerous pattern.
106
+ def dangerous?(command)
107
+ detect(command).first
108
+ end
109
+
110
+ # Same normalization idiom as HardlineGuard: collapse spaces/tabs (keep
111
+ # newlines so separator anchors fire), trim, lowercase. Trivial
112
+ # obfuscation (extra spaces, case) doesn't slip through.
113
+ def normalize(command)
114
+ command.to_s.gsub(/[ \t]+/, " ").strip.downcase
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Persists an explicit "deny always" verdict to the `permissions` map so it
6
+ # survives a process restart and auto-denies future sibling commands through
7
+ # ApprovalPolicy#decide, which evaluates a permissions:deny rule FIRST (before
8
+ # any allow path — see approval_policy.rb step 2).
9
+ #
10
+ # The DENY counterpart to AllowlistPersister: same Config::Writer (dot-notation
11
+ # -> YAML) + live-config sync, but it writes into `permissions` instead of
12
+ # `security.command_allowlist`, and the value is the verdict "deny" keyed by a
13
+ # PatternMatcher-format pattern ("<tool> <glob>") rather than a bare prefix.
14
+ #
15
+ # The pattern is derived from the SAME PrefixDeriver rule the allow side uses,
16
+ # so "deny always" is scoped consistently with "always allow":
17
+ # :prefix -> "<tool> <head>*" (e.g. "shell git*" — denies every sibling)
18
+ # :command -> "<tool> <command>" (e.g. "shell rm -rf /tmp/x" — exact)
19
+ # :pattern -> "<tool> <command>" (a dangerous-pattern description is not a
20
+ # command glob, so deny the exact command)
21
+ #
22
+ # Append-unique: an already-present "<pattern>": "deny" entry is a no-op.
23
+ #
24
+ # SCOPING NOTE: identical to AllowlistPersister — Config::Writer writes the
25
+ # process-global config.yml. Fine for a single-process / single-home setup; a
26
+ # shared-server deployment would need per-user config scoping.
27
+ module DenyPersister
28
+ KEY = "permissions"
29
+ DENY = "deny"
30
+
31
+ module_function
32
+
33
+ # Persists a permissions:deny rule for `pattern` (unique). Returns the
34
+ # resulting permissions hash. A blank pattern is a no-op.
35
+ def persist(pattern, config: nil, config_path: nil)
36
+ key = pattern.to_s.strip
37
+ return current_permissions(config) if key.empty?
38
+
39
+ config ||= Rubino.configuration
40
+ existing = current_permissions(config)
41
+ return existing if existing[key] == DENY
42
+
43
+ updated = existing.merge(key => DENY)
44
+ Config::Writer.new(config_path: config_path || default_config_path).set(KEY, updated)
45
+ # Keep the live config in sync so an ApprovalPolicy built this process
46
+ # sees the new deny rule immediately (the writer only touches disk).
47
+ config.set(KEY, updated)
48
+ updated
49
+ end
50
+
51
+ # The PatternMatcher-format key a (tool, rule, command) "deny always"
52
+ # persists as. Mirrors the allow side's scoping: a derivable :prefix denies
53
+ # the whole prefix class ("<tool> <head>*"); everything else denies the
54
+ # exact command ("<tool> <command>"). Nil when there is nothing to key on.
55
+ def pattern_for(tool:, rule:, command:)
56
+ cmd = command.to_s.strip
57
+ if rule&.kind == :prefix && !rule.value.to_s.strip.empty?
58
+ "#{tool} #{rule.value.strip}*"
59
+ elsif !cmd.empty?
60
+ "#{tool} #{cmd}"
61
+ end
62
+ end
63
+
64
+ def current_permissions(config)
65
+ ((config || Rubino.configuration).dig("permissions") || {}).dup
66
+ end
67
+
68
+ def default_config_path
69
+ Config::Loader.new.config_path
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # Detects when the agent enters a doom loop - repeatedly calling
6
+ # the same tool with identical arguments without progress.
7
+ class DoomLoopDetector
8
+ DEFAULT_THRESHOLD = 3
9
+
10
+ def initialize(threshold: DEFAULT_THRESHOLD)
11
+ @threshold = threshold
12
+ @history = []
13
+ end
14
+
15
+ # Records a tool call and returns true if a doom loop is detected
16
+ def record(tool_name:, arguments:)
17
+ signature = generate_signature(tool_name, arguments)
18
+ @history << signature
19
+
20
+ # Check if the last N calls are identical
21
+ if @history.size >= @threshold
22
+ recent = @history.last(@threshold)
23
+ return true if recent.uniq.size == 1
24
+ end
25
+
26
+ false
27
+ end
28
+
29
+ # Resets the detector (e.g., when user provides new input)
30
+ def reset!
31
+ @history.clear
32
+ end
33
+
34
+ private
35
+
36
+ def generate_signature(tool_name, arguments)
37
+ # Create a deterministic signature from tool name + sorted arguments
38
+ args_str = arguments.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}=#{v}" }.join("&")
39
+ "#{tool_name}:#{args_str}"
40
+ end
41
+ end
42
+ end
43
+ end