rubino-agent 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +11 -2
  3. data/AGENTS.md +1 -1
  4. data/CHANGELOG.md +172 -5
  5. data/CONTRIBUTING.md +10 -1
  6. data/README.md +14 -5
  7. data/Rakefile +31 -0
  8. data/docs/agents.md +42 -23
  9. data/docs/architecture.md +2 -2
  10. data/docs/commands.md +35 -3
  11. data/docs/configuration.md +20 -23
  12. data/docs/getting-started.md +5 -3
  13. data/docs/security.md +16 -5
  14. data/docs/skills.md +31 -0
  15. data/docs/troubleshooting.md +1 -1
  16. data/exe/rubino +16 -2
  17. data/install.sh +721 -59
  18. data/lib/rubino/active_agent.rb +73 -0
  19. data/lib/rubino/agent/action_claim_guard.rb +881 -0
  20. data/lib/rubino/agent/agent_registry.rb +5 -2
  21. data/lib/rubino/agent/definition.rb +1 -9
  22. data/lib/rubino/agent/fallback_chain.rb +0 -6
  23. data/lib/rubino/agent/iteration_budget.rb +109 -3
  24. data/lib/rubino/agent/loop.rb +476 -20
  25. data/lib/rubino/agent/model_call_runner.rb +81 -3
  26. data/lib/rubino/agent/prompts/build.txt +22 -5
  27. data/lib/rubino/agent/response_validator.rb +8 -0
  28. data/lib/rubino/agent/runner.rb +133 -8
  29. data/lib/rubino/agent/tool_executor.rb +166 -14
  30. data/lib/rubino/agent/truncation_continuation.rb +4 -1
  31. data/lib/rubino/api/server.rb +19 -0
  32. data/lib/rubino/attachments/classify.rb +35 -17
  33. data/lib/rubino/boot/config_guard.rb +71 -0
  34. data/lib/rubino/cli/chat/completion_builder.rb +42 -6
  35. data/lib/rubino/cli/chat/idle_card_host.rb +7 -1
  36. data/lib/rubino/cli/chat/session_resolver.rb +87 -21
  37. data/lib/rubino/cli/chat_command.rb +1189 -50
  38. data/lib/rubino/cli/commands.rb +282 -2
  39. data/lib/rubino/cli/config_command.rb +68 -8
  40. data/lib/rubino/cli/doctor_command.rb +204 -12
  41. data/lib/rubino/cli/jobs_command.rb +12 -0
  42. data/lib/rubino/cli/memory_command.rb +53 -20
  43. data/lib/rubino/cli/onboarding_wizard.rb +79 -6
  44. data/lib/rubino/cli/session_command.rb +172 -18
  45. data/lib/rubino/cli/setup_command.rb +131 -8
  46. data/lib/rubino/cli/skills_command.rb +183 -9
  47. data/lib/rubino/cli/trust_gate.rb +16 -7
  48. data/lib/rubino/commands/built_ins.rb +2 -0
  49. data/lib/rubino/commands/command.rb +12 -2
  50. data/lib/rubino/commands/executor.rb +149 -12
  51. data/lib/rubino/commands/handlers/agent_switch.rb +100 -0
  52. data/lib/rubino/commands/handlers/agents.rb +156 -41
  53. data/lib/rubino/commands/handlers/config.rb +4 -1
  54. data/lib/rubino/commands/handlers/help.rb +113 -14
  55. data/lib/rubino/commands/handlers/memory.rb +15 -5
  56. data/lib/rubino/commands/handlers/sessions.rb +26 -3
  57. data/lib/rubino/commands/handlers/status.rb +9 -4
  58. data/lib/rubino/commands/loader.rb +12 -0
  59. data/lib/rubino/config/configuration.rb +86 -24
  60. data/lib/rubino/config/defaults.rb +140 -33
  61. data/lib/rubino/config/loader.rb +62 -12
  62. data/lib/rubino/config/validator.rb +341 -0
  63. data/lib/rubino/config/writer.rb +123 -31
  64. data/lib/rubino/context/compressor.rb +184 -22
  65. data/lib/rubino/context/environment_inspector.rb +2 -2
  66. data/lib/rubino/context/file_discovery.rb +2 -2
  67. data/lib/rubino/context/message_boundary.rb +27 -1
  68. data/lib/rubino/context/project_languages.rb +90 -0
  69. data/lib/rubino/context/prompt_assembler.rb +105 -22
  70. data/lib/rubino/context/summary_builder.rb +45 -4
  71. data/lib/rubino/context/token_budget.rb +36 -11
  72. data/lib/rubino/context/token_estimate.rb +45 -0
  73. data/lib/rubino/context/tool_result_pruner.rb +81 -0
  74. data/lib/rubino/database/connection.rb +154 -3
  75. data/lib/rubino/database/migrations/001_create_initial_schema.rb +314 -40
  76. data/lib/rubino/database/migrator.rb +98 -5
  77. data/lib/rubino/documents/cap_exceeded.rb +13 -0
  78. data/lib/rubino/documents/converters/csv.rb +4 -3
  79. data/lib/rubino/documents/converters/docx.rb +29 -5
  80. data/lib/rubino/documents/converters/html.rb +5 -1
  81. data/lib/rubino/documents/converters/json.rb +2 -1
  82. data/lib/rubino/documents/converters/pdf.rb +11 -2
  83. data/lib/rubino/documents/converters/plain.rb +2 -1
  84. data/lib/rubino/documents/converters/pptx.rb +11 -2
  85. data/lib/rubino/documents/converters/xlsx.rb +35 -4
  86. data/lib/rubino/documents/converters/xml.rb +2 -1
  87. data/lib/rubino/documents/limits.rb +210 -0
  88. data/lib/rubino/documents.rb +10 -3
  89. data/lib/rubino/errors.rb +36 -5
  90. data/lib/rubino/interaction/cancel_token.rb +19 -3
  91. data/lib/rubino/interaction/events.rb +13 -0
  92. data/lib/rubino/interaction/lifecycle.rb +99 -13
  93. data/lib/rubino/interaction/polishing.rb +176 -0
  94. data/lib/rubino/jobs/cron_job_repository.rb +5 -8
  95. data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +11 -0
  96. data/lib/rubino/jobs/handlers/distill_skill_job.rb +65 -9
  97. data/lib/rubino/jobs/queue.rb +63 -8
  98. data/lib/rubino/jobs/runner.rb +24 -6
  99. data/lib/rubino/jobs/worker.rb +0 -4
  100. data/lib/rubino/llm/adapter_response.rb +47 -4
  101. data/lib/rubino/llm/credential_check.rb +15 -16
  102. data/lib/rubino/llm/error_classifier.rb +89 -1
  103. data/lib/rubino/llm/inline_think_filter.rb +69 -12
  104. data/lib/rubino/llm/request.rb +30 -3
  105. data/lib/rubino/llm/ruby_llm_adapter.rb +394 -46
  106. data/lib/rubino/llm/tool_bridge.rb +113 -9
  107. data/lib/rubino/mcp/manager.rb +18 -1
  108. data/lib/rubino/mcp/mcp_tool_wrapper.rb +14 -3
  109. data/lib/rubino/memory/aux_retry.rb +107 -0
  110. data/lib/rubino/memory/backends/sqlite.rb +73 -44
  111. data/lib/rubino/memory/backends.rb +23 -7
  112. data/lib/rubino/memory/salience_gate.rb +103 -0
  113. data/lib/rubino/memory/sqlite_extraction.rb +70 -0
  114. data/lib/rubino/memory/sqlite_extraction_prompt.rb +11 -0
  115. data/lib/rubino/memory/store.rb +33 -5
  116. data/lib/rubino/memory/threat_scanner.rb +52 -0
  117. data/lib/rubino/output/cost.rb +52 -0
  118. data/lib/rubino/output/headless_block_latch.rb +53 -0
  119. data/lib/rubino/output/result_serializer.rb +222 -0
  120. data/lib/rubino/output/turn_recorder.rb +77 -0
  121. data/lib/rubino/security/approval_policy.rb +227 -32
  122. data/lib/rubino/security/command_allowlist.rb +79 -4
  123. data/lib/rubino/security/doom_loop_detector.rb +21 -2
  124. data/lib/rubino/security/hardline_guard.rb +189 -16
  125. data/lib/rubino/security/pattern_matcher.rb +28 -5
  126. data/lib/rubino/security/prefix_deriver.rb +25 -6
  127. data/lib/rubino/security/readonly_commands.rb +145 -5
  128. data/lib/rubino/security/secret_path.rb +134 -0
  129. data/lib/rubino/security/url_safety.rb +255 -0
  130. data/lib/rubino/session/repository.rb +212 -11
  131. data/lib/rubino/session/store.rb +139 -14
  132. data/lib/rubino/skills/installer.rb +230 -0
  133. data/lib/rubino/skills/prompt_index.rb +2 -2
  134. data/lib/rubino/skills/registry.rb +52 -1
  135. data/lib/rubino/skills/skill.rb +64 -3
  136. data/lib/rubino/skills/skill_tool.rb +16 -5
  137. data/lib/rubino/tools/background_tasks.rb +157 -13
  138. data/lib/rubino/tools/base.rb +204 -3
  139. data/lib/rubino/tools/edit_tool.rb +73 -18
  140. data/lib/rubino/tools/glob_tool.rb +48 -9
  141. data/lib/rubino/tools/grep_tool.rb +103 -9
  142. data/lib/rubino/tools/multi_edit_tool.rb +64 -9
  143. data/lib/rubino/tools/patch_tool.rb +5 -0
  144. data/lib/rubino/tools/read_attachment_tool.rb +3 -1
  145. data/lib/rubino/tools/read_tool.rb +33 -15
  146. data/lib/rubino/tools/read_tracker.rb +153 -35
  147. data/lib/rubino/tools/registry.rb +113 -12
  148. data/lib/rubino/tools/result.rb +9 -1
  149. data/lib/rubino/tools/ruby_tool.rb +0 -0
  150. data/lib/rubino/tools/shell_registry.rb +70 -0
  151. data/lib/rubino/tools/shell_tool.rb +40 -1
  152. data/lib/rubino/tools/summarize_file_tool.rb +6 -0
  153. data/lib/rubino/tools/task_stop_tool.rb +10 -16
  154. data/lib/rubino/tools/task_tool.rb +36 -8
  155. data/lib/rubino/tools/vision_tool.rb +5 -0
  156. data/lib/rubino/tools/webfetch_tool.rb +39 -7
  157. data/lib/rubino/tools/websearch_tool.rb +92 -30
  158. data/lib/rubino/tools/write_tool.rb +23 -4
  159. data/lib/rubino/ui/api.rb +10 -1
  160. data/lib/rubino/ui/base.rb +11 -0
  161. data/lib/rubino/ui/bottom_composer.rb +382 -74
  162. data/lib/rubino/ui/cli.rb +515 -83
  163. data/lib/rubino/ui/completion_menu.rb +11 -7
  164. data/lib/rubino/ui/headless_trace.rb +63 -0
  165. data/lib/rubino/ui/live_region.rb +70 -7
  166. data/lib/rubino/ui/markdown_renderer.rb +142 -7
  167. data/lib/rubino/ui/notifier.rb +0 -2
  168. data/lib/rubino/ui/null.rb +52 -5
  169. data/lib/rubino/ui/paste_store.rb +16 -2
  170. data/lib/rubino/ui/queued_indicators.rb +6 -1
  171. data/lib/rubino/ui/status_bar.rb +61 -7
  172. data/lib/rubino/ui/streaming_markdown.rb +59 -6
  173. data/lib/rubino/ui/subagent_view.rb +29 -4
  174. data/lib/rubino/ui/tool_label.rb +52 -0
  175. data/lib/rubino/update_check.rb +39 -4
  176. data/lib/rubino/util/atomic_file.rb +117 -0
  177. data/lib/rubino/util/ignore_rules.rb +120 -0
  178. data/lib/rubino/util/output.rb +229 -12
  179. data/lib/rubino/util/secrets_mask.rb +70 -7
  180. data/lib/rubino/util/spill_store.rb +153 -0
  181. data/lib/rubino/version.rb +1 -1
  182. data/lib/rubino/workspace.rb +9 -1
  183. data/lib/rubino.rb +191 -7
  184. data/rubino-agent.gemspec +1 -0
  185. data/skills/ruby-expert/SKILL.md +1 -0
  186. metadata +42 -12
  187. data/lib/rubino/agent/router.rb +0 -65
  188. data/lib/rubino/database/migrations/002_create_runs.rb +0 -45
  189. data/lib/rubino/database/migrations/003_create_skill_states.rb +0 -15
  190. data/lib/rubino/database/migrations/004_create_cron_jobs.rb +0 -36
  191. data/lib/rubino/database/migrations/005_create_oauth_connections.rb +0 -27
  192. data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +0 -34
  193. data/lib/rubino/database/migrations/007_create_messages_fts.rb +0 -59
  194. data/lib/rubino/database/migrations/008_create_memory_facts.rb +0 -75
  195. data/lib/rubino/database/migrations/009_create_memory_graph.rb +0 -55
  196. data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +0 -20
@@ -16,6 +16,20 @@ module Rubino
16
16
  class ApprovalPolicy
17
17
  MODES = %w[manual auto skip].freeze
18
18
 
19
+ # Structured in-workspace file-edit tools. Under dangerous_only these run
20
+ # unprompted — SYMMETRIC with safe shell — because the always-on #413
21
+ # write-denylist + workspace sandbox (both enforced inside the tool's
22
+ # #call, regardless of approval) are the boundary, not a per-edit prompt
23
+ # (#427, mirrors Hermes file_safety + Claude Code acceptEdits / Codex
24
+ # auto-edit / aider).
25
+ STRUCTURED_EDIT_TOOLS = %w[edit write multi_edit apply_patch].freeze
26
+
27
+ # File tools whose TARGET path is run through the unified secret-file gate
28
+ # (#446). READ side resolves the path from `file_path`/`path`; WRITE side
29
+ # from `file_path` (apply_patch from its patch text, see #secret_file_access?).
30
+ SECRET_GATED_READ_TOOLS = %w[read grep glob].freeze
31
+ SECRET_GATED_WRITE_TOOLS = STRUCTURED_EDIT_TOOLS
32
+
19
33
  # Why the most recent #decide returned :deny — :hardline (the
20
34
  # non-bypassable floor), :permission_rule (an explicit permissions deny
21
35
  # rule), or :doom_loop (the repeated-identical-call guard). nil when the
@@ -27,18 +41,34 @@ module Rubino
27
41
  def initialize(config: nil, agent_overrides: nil)
28
42
  @config = config || Rubino.configuration
29
43
  @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.
44
+ # Effective shell prompt policy (:confirm_all | :dangerous_only), the
45
+ # SOLE source of truth (item 7): security.confirm_policy only — the legacy
46
+ # security.require_confirmation_for_shell alias was removed (see
47
+ # Configuration#confirm_policy). Older config objects that predate the
48
+ # accessor fall back to the reference-faithful :dangerous_only default.
34
49
  @confirm_policy =
35
- @config.respond_to?(:confirm_policy) ? @config.confirm_policy : :confirm_all
50
+ @config.respond_to?(:confirm_policy) ? @config.confirm_policy : :dangerous_only
36
51
  @pattern_matcher = PatternMatcher.new(
37
52
  rules: load_permission_rules(agent_overrides)
38
53
  )
39
- @doom_detector = DoomLoopDetector.new
54
+ # Doom-loop guard, config-driven (#414). Default WARN-not-block with a
55
+ # higher threshold (Hermes tool_guardrails alignment): a tripped detector
56
+ # under hard_stop:false surfaces a warning but lets the call run.
57
+ @doom_detector = DoomLoopDetector.new(
58
+ threshold: @config.respond_to?(:doom_loop_threshold) ? @config.doom_loop_threshold : DoomLoopDetector::DEFAULT_THRESHOLD,
59
+ hard_stop: @config.respond_to?(:doom_loop_hard_stop?) ? @config.doom_loop_hard_stop? : false
60
+ )
61
+ # Set true after a warn-mode doom-loop hit so ToolExecutor can surface a
62
+ # one-time warning to the model without denying the call. Cleared each
63
+ # #decide and on reset_turn!.
64
+ @doom_loop_warning = false
40
65
  end
41
66
 
67
+ # True when the LAST #decide tripped the doom-loop guard in WARN mode
68
+ # (hard_stop off): the call was allowed but the model should be told it is
69
+ # repeating an identical call. ToolExecutor reads this to attach a warning.
70
+ attr_reader :doom_loop_warning
71
+
42
72
  # Returns the decision for a tool call: :allow, :ask, :deny
43
73
  #
44
74
  # CANONICAL DECISION ORDER (deny-class checks precede every allow path).
@@ -46,14 +76,22 @@ module Rubino
46
76
  #
47
77
  # 1. hardline(:deny) non-bypassable floor BELOW yolo
48
78
  # 2. permissions:deny an explicit deny rule also beats yolo
49
- # 3. yolo / skip-approvals allow-exit (doom still guards it)
79
+ # 3. runtime yolo (Modes) allow-exit (doom still guards it).
80
+ # config approvals.mode: "skip" does NOT
81
+ # take this exit — it is not a headless
82
+ # yolo (see steps 7-9 / #260).
50
83
  # 4. doom loop break a stuck autopilot
51
84
  # 5. permissions:allow / :ask remaining explicit rules
52
- # 6. command_allowlist (prefix) pre-approved commands -> :allow
85
+ # 6. command_allowlist pre-approved EXACT commands -> :allow
86
+ # (chain-aware, token-boundary; never a
87
+ # prefix of a compound line)
53
88
  # 6b. readonly auto-allow parse-validated read-only shell -> :allow
54
89
  # 7-8. confirm_policy shell gate confirm_all -> :ask; dangerous_only
55
- # -> :ask only if dangerous?, else :allow
56
- # 9. mode fallback
90
+ # -> :ask only if dangerous?, else :allow.
91
+ # Runs for mode "skip" too, so a write/
92
+ # shell under config "skip" still reaches
93
+ # the headless fail-closed floor (#260).
94
+ # 9. mode fallback ("skip" -> :ask for risky tools, not :allow)
57
95
  #
58
96
  # The invariant that makes this slice worth doing: HARDLINE and an
59
97
  # explicit permissions:deny BOTH run before any allow path (yolo,
@@ -61,6 +99,7 @@ module Rubino
61
99
  # by a fast-path the way yolo used to override deny rules.
62
100
  def decide(tool, arguments: {})
63
101
  @last_deny_reason = nil
102
+ @doom_loop_warning = false
64
103
  command_str = self.class.command_string(tool, arguments)
65
104
 
66
105
  # 1. Hardline floor — a floor BELOW yolo. Catastrophic, unrecoverable
@@ -87,20 +126,36 @@ module Rubino
87
126
  # run the doom detector AFTER, because an autopilot stuck in a loop
88
127
  # is the one thing yolo isn't supposed to license.
89
128
  if Rubino::Modes.skip_approvals?
90
- return deny_with(:doom_loop) if @doom_detector.record(tool_name: tool.name, arguments: arguments)
129
+ return deny_with(:doom_loop) if doom_loop_blocks?(tool, arguments)
91
130
 
92
131
  return :allow
93
132
  end
94
133
 
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
134
+ # 4. Doom loop guard. Blocks only under hard_stop (#414); in the default
135
+ # warn mode it sets @doom_loop_warning and falls through to the normal
136
+ # decision so a legitimate repeated call is not hard-denied.
137
+ return deny_with(:doom_loop) if doom_loop_blocks?(tool, arguments)
99
138
 
100
139
  # 5. Remaining explicit pattern rules (allow / ask). deny was already
101
- # handled in step 2.
140
+ # handled in step 2. An explicit user permissions rule (allow/ask)
141
+ # wins over the secret gate below, so a user who wrote
142
+ # `read /path/.env: allow` is honored.
102
143
  return pattern_result if pattern_result
103
144
 
145
+ # 5b. UNIFIED SECRET-FILE GATE (#446). Reading (read/grep/glob) OR
146
+ # writing/editing (write/edit/multi_edit/apply_patch) a SECRET path
147
+ # requires EXPLICIT user approval — the maintainer decision: not a
148
+ # silent allow, not a silent hard-block. Returns :ask, which the
149
+ # ToolExecutor turns into the approval dropdown when interactive
150
+ # (approved → the tool runs and reads/writes the secret; denied →
151
+ # refused) and into a FAIL-CLOSED block when headless (:noninteractive).
152
+ # Runs ABOVE the broad read/allow fast-paths (steps 6/6b/9) so a
153
+ # secret read isn't silently auto-allowed, and BELOW yolo (step 3) so
154
+ # a --yolo operator who opted into full file trust isn't re-prompted.
155
+ # NON-secret reads stay broad (clone-and-inspect, #406) — only the
156
+ # secret set is gated.
157
+ return :ask if secret_file_access?(tool, arguments)
158
+
104
159
  # 6. Config allowlist of pre-approved commands. Checked AFTER deny
105
160
  # patterns (deny always wins) but BEFORE mode-based decision so a
106
161
  # listed command never triggers a manual prompt.
@@ -114,32 +169,70 @@ module Rubino
114
169
  # validator cannot prove read-only falls through to the prompt.
115
170
  return :allow if readonly_auto_allowed?(tool, command_str)
116
171
 
172
+ # 6c. skill(action: "create") WRITES <RUBINO_HOME>/skills/<name>/SKILL.md
173
+ # and must not be a silent low-risk allow (#405): the skill tool stays
174
+ # :low so a read_only agent keeps `skill load/list/show`, but the
175
+ # create action is a write and routes to :ask here — like any write.
176
+ # Below yolo (step 3), so a full-access --yolo agent still creates
177
+ # skills inline; a headless read_only subagent's :ask becomes the
178
+ # fail-closed block, closing the unapproved-write path. load is never
179
+ # gated (only the create action matches).
180
+ #
181
+ # This gate is now a real boundary, not theater (SK-2): authored
182
+ # skills are written under the agent HOME (outside the cwd workspace),
183
+ # so the model can't sidestep it by emitting a plain `write` of the
184
+ # same SKILL.md — the workspace sandbox (within_workspace?) refuses any
185
+ # write outside the workspace, leaving this :ask-gated helper as the
186
+ # ONLY way to author a skill.
187
+ return :ask if skill_create?(tool, arguments)
188
+
117
189
  # 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".
190
+ # NOT under runtime yolo (handled at step 3) — that is the explicit
191
+ # CLI operator override that means "stop prompting me".
121
192
  #
122
- # confirm_all (DEFAULT, == legacy require_confirmation_for_shell:true)
193
+ # config approvals.mode: "skip" is NOT given the same allow-exit as
194
+ # runtime yolo here. #260 deliberately made the headless skip a
195
+ # CLI-only opt-in (--yolo): a config-file "skip" must NOT silently
196
+ # auto-run write/shell in a headless session. So a not-otherwise-
197
+ # resolved shell command still routes through this gate to :ask, and
198
+ # the ToolExecutor's headless fail-closed floor (#260) turns that
199
+ # :ask into a block when there is no interactive session. Interactive
200
+ # sessions still get a prompt — same as auto/manual. (Reads are
201
+ # already auto-allowed by step 6b / mode_based_decision, so this
202
+ # only constrains the write/shell side.)
203
+ #
204
+ # confirm_all (opt-in hardening)
123
205
  # every such shell command -> :ask. shell is :high risk so manual
124
206
  # mode would ask anyway; this also keeps it gated under auto mode.
125
207
  #
126
- # dangerous_only (reference-faithful, == legacy alias:false)
208
+ # dangerous_only (DEFAULT, reference-faithful)
127
209
  # prompt ONLY when the command matches a DangerousPattern
128
210
  # (git push --force, curl|sh, recursive rm of a non-root path,
129
211
  # ...). Safe commands run unprompted. Mirrors approval.py:475
130
212
  # where detect_dangerous_command is the sole prompt trigger.
131
213
  # The hardline floor (step 1) and permissions:deny (step 2) already
132
214
  # 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
215
+ return shell_confirm_decision(command_str) if tool.name == "shell"
216
+
217
+ # 8b. Structured in-workspace edit symmetry (#427). Under dangerous_only,
218
+ # a safe `shell sed -i …` / `echo > file` runs UNPROMPTED (step 7-8),
219
+ # but the structured edit/write/multi_edit/apply_patch tools are
220
+ # :medium and would fall through to step 9 -> :ask, which fails closed
221
+ # headless. That asymmetry pushes automation AWAY from the clean,
222
+ # read-tracked, diff-producing structured tools and TOWARD raw shell
223
+ # mutation — worse for safety/observability and the inverse of the
224
+ # industry norm (Hermes runs structured in-workspace edits unprompted
225
+ # with file_safety.is_write_denied as the boundary; Claude Code
226
+ # acceptEdits, Codex auto-edit and aider all treat in-workspace edits
227
+ # as LOWER friction than shell). So under dangerous_only these
228
+ # structured edits are non-prompting too — SYMMETRIC with safe shell.
229
+ # This NEVER widens reach: the always-on #413 write-denylist (refuses
230
+ # .env/.ssh/.aws/etc even inside the workspace) and the workspace
231
+ # sandbox both run inside the tool's #call regardless of approval, and
232
+ # the hardline floor (step 1), permissions:deny (step 2) and
233
+ # skill-create gate (step 6c) all already ran above. confirm_all
234
+ # (non-default) still routes them through step 9 -> :ask unchanged.
235
+ return :allow if @confirm_policy == :dangerous_only && STRUCTURED_EDIT_TOOLS.include?(tool.name)
143
236
 
144
237
  # 9. Fall back to mode-based decision
145
238
  mode_based_decision(tool)
@@ -159,6 +252,78 @@ module Rubino
159
252
  CommandAllowlist.new(config: @config).allowed?(command)
160
253
  end
161
254
 
255
+ # True when this is the WRITE action of the skill tool (action: "create").
256
+ # The skill tool is :low (so read_only keeps load/list/show), but its
257
+ # create action writes a SKILL.md and must be approval-gated (#405).
258
+ def skill_create?(tool, arguments)
259
+ return false unless tool.name == "skill"
260
+
261
+ args = arguments || {}
262
+ (args["action"] || args[:action]).to_s == "create"
263
+ end
264
+
265
+ # The confirm_policy shell gate (steps 7-8), extracted so #decide stays
266
+ # under the complexity limit. confirm_all → always :ask; dangerous_only →
267
+ # :ask only for a DangerousPattern, else :allow.
268
+ def shell_confirm_decision(command_str)
269
+ return :ask unless @confirm_policy == :dangerous_only
270
+
271
+ dangerous?(command_str) ? :ask : :allow
272
+ end
273
+
274
+ # True when this call READS or WRITES a secret/credential path and so must
275
+ # be approval-gated (#446). For the path-arg tools (read/grep/glob/write/
276
+ # edit/multi_edit) the single target is resolved from file_path/path; for
277
+ # apply_patch every target file in the patch is checked, because one call
278
+ # can touch many files. Resolution is relative to the workspace primary
279
+ # root so a relative `.env` resolves to the same file the tool will open.
280
+ def secret_file_access?(tool, arguments)
281
+ return false unless SECRET_GATED_READ_TOOLS.include?(tool.name) ||
282
+ SECRET_GATED_WRITE_TOOLS.include?(tool.name)
283
+
284
+ secret_targets(tool, arguments).any? { |p| SecretPath.secret?(p) }
285
+ end
286
+
287
+ # The absolute path(s) a file tool will touch. apply_patch yields one per
288
+ # hunk target; every other gated tool yields its single file_path/path.
289
+ def secret_targets(tool, arguments)
290
+ args = arguments || {}
291
+ if tool.name == "apply_patch"
292
+ base = (args["base_path"] || args[:base_path]).to_s
293
+ base = Tools::Base.workspace_root if base.empty?
294
+ return patch_target_paths(args["patch"] || args[:patch], base)
295
+ end
296
+
297
+ raw = self.class.command_string(tool, arguments)
298
+ return [] if raw.to_s.empty?
299
+
300
+ [resolve_workspace_path(raw)]
301
+ end
302
+
303
+ # Extracts every destination file from a unified diff (`+++ b/<file>`, and
304
+ # `--- a/<file>` so a delete of a secret is gated too), absolutised against
305
+ # base_path. A `/dev/null` side carries no file and is skipped.
306
+ def patch_target_paths(patch, base_path)
307
+ return [] if patch.nil?
308
+
309
+ patch.to_s.each_line.filter_map do |line|
310
+ m = line.match(%r{^[-+]{3} [ab]/(.+)\s*$})
311
+ next if m.nil?
312
+
313
+ File.expand_path(m[1].strip, base_path)
314
+ end.uniq
315
+ end
316
+
317
+ # Anchors a relative path at the workspace primary root (matching
318
+ # Tools::Base#expand_workspace_path) so the gate sees the same target the
319
+ # tool will. Absolute/~ paths pass through.
320
+ def resolve_workspace_path(path)
321
+ str = path.to_s
322
+ return File.expand_path(str) if str.start_with?(File::SEPARATOR, "~")
323
+
324
+ File.expand_path(str, Tools::Base.workspace_root)
325
+ end
326
+
162
327
  # True when the shell command is provably read-only and the
163
328
  # approvals.auto_allow_readonly gate (default ON) is open. Shell-only:
164
329
  # for every other tool the "command" is a path or argument fragment.
@@ -180,8 +345,18 @@ module Rubino
180
345
  (args["command"] || args[:command]).to_s
181
346
  when "read", "write", "edit", "multi_edit", "attach_file"
182
347
  (args["file_path"] || args[:file_path]).to_s
348
+ when "grep", "glob"
349
+ # The SEARCH ROOT (a dir or a file) is what the secret gate resolves —
350
+ # `pattern` is the regex/glob, not a path. (Default "." like the tools.)
351
+ (args["path"] || args[:path] || ".").to_s
183
352
  when "shell_output", "shell_kill", "shell_input"
184
353
  (args["run_id"] || args[:run_id]).to_s
354
+ when "skill"
355
+ # "<action> <name>" so the approval scope distinguishes a create from
356
+ # a load and one skill name from another (granularity parity, #405).
357
+ action = args["action"] || args[:action] || "load"
358
+ name = args["name"] || args[:name]
359
+ [action, name].join(" ").strip
185
360
  else
186
361
  args.values.first.to_s
187
362
  end
@@ -194,6 +369,21 @@ module Rubino
194
369
 
195
370
  private
196
371
 
372
+ # Records the tool call in the doom detector and returns true ONLY when it
373
+ # tripped AND the guard is in hard_stop mode (=> block). In the default
374
+ # warn mode a trip sets @doom_loop_warning and returns false, so the call
375
+ # proceeds through the normal decision path (#414).
376
+ def doom_loop_blocks?(tool, arguments)
377
+ return false unless @doom_detector.record(tool_name: tool.name, arguments: arguments)
378
+
379
+ if @doom_detector.hard_stop?
380
+ true
381
+ else
382
+ @doom_loop_warning = true
383
+ false
384
+ end
385
+ end
386
+
197
387
  # Records WHY this deny fired before returning it (see #last_deny_reason).
198
388
  def deny_with(reason)
199
389
  @last_deny_reason = reason
@@ -202,8 +392,13 @@ module Rubino
202
392
 
203
393
  def mode_based_decision(tool)
204
394
  case @mode
395
+ # config approvals.mode: "skip" is NOT a headless yolo (#260). It stays
396
+ # permissive for non-risky tools (reads), but a risky tool (write/edit/
397
+ # shell) routes to :ask so the headless fail-closed floor can block it
398
+ # when there is no interactive session — only runtime --yolo (step 3)
399
+ # may auto-run those headless. Interactive sessions still get a prompt.
205
400
  when "skip"
206
- :allow
401
+ tool.risky? ? :ask : :allow
207
402
  when "auto"
208
403
  tool.risk_level == :high ? :ask : :allow
209
404
  when "manual"
@@ -1,24 +1,99 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+
3
5
  module Rubino
4
6
  module Security
5
- # Manages a whitelist of shell commands that can be executed without confirmation.
7
+ # Manages a whitelist of shell commands that can be executed without
8
+ # confirmation.
9
+ #
10
+ # An allowlist entry pre-approves an EXACT single command, never a prefix
11
+ # of a larger compound line. A naive `start_with?` (the old behaviour) let
12
+ # any line whose head matched an entry auto-resolve to :allow — INCLUDING
13
+ # the chained tail: with `git status` allowlisted,
14
+ # `git status; echo k >> ~/.ssh/authorized_keys` resolved to :allow,
15
+ # turning a read-only pre-approval into headless RCE/exfil. So this matcher
16
+ # is chain-aware, mirroring ReadonlyCommands:
17
+ #
18
+ # - DangerousPatterns runs FIRST on the whole line, so a dangerous tail
19
+ # (curl|sh, recursive rm, write into ~/.ssh, ...) can never be beaten
20
+ # by an allowlisted head;
21
+ # - the line is split into chain segments (|, ||, &&, ;, newline) with the
22
+ # same quote-aware splitter as ReadonlyCommands, which REJECTS the line
23
+ # outright on redirection (>), backgrounding (&), command substitution
24
+ # ($(...) / backticks) or process substitution (<(...) / >()) — the
25
+ # constructs that smuggle a write or an execution past a head check;
26
+ # - EVERY segment must match an allowlist entry, and a match is on a TOKEN
27
+ # boundary (a prefix of token tokens), never a bare substring: `git`
28
+ # allowlisted does NOT pre-approve `git-secret-leak`, and `git status`
29
+ # does NOT pre-approve `git statusxyz`;
30
+ # - a matched head is FLAG-VETTED via ReadonlyCommands: an allowlisted
31
+ # read-capable head can not smuggle a write/exec flag past the prefix
32
+ # match. With `git diff` allowlisted, `git diff --output /tmp/PWN`
33
+ # (an arbitrary write) is REJECTED — same for `git diff -O...`,
34
+ # `find -exec/-delete/-fprintf`, `date -s`, `tree -o` (SEC-1).
6
35
  class CommandAllowlist
7
36
  def initialize(config: nil)
8
37
  @config = config || Rubino.configuration
9
38
  @allowlist = @config.security_command_allowlist
10
39
  end
11
40
 
12
- # Returns true if the command matches an entry in the allowlist.
41
+ # Returns true ONLY when the ENTIRE command line is covered by the
42
+ # allowlist: not dangerous, splits cleanly into chain segments, and every
43
+ # segment's head matches an allowlist entry on a token boundary.
44
+ #
13
45
  # An EMPTY allowlist matches NOTHING — pre-approval is opt-in, so an
14
46
  # unconfigured allowlist must never auto-approve everything.
15
47
  def allowed?(command)
16
48
  return false if @allowlist.empty?
49
+ return false if DangerousPatterns.dangerous?(command)
50
+
51
+ entries = allowlist_token_lists
52
+ return false if entries.empty?
53
+
54
+ segments = ReadonlyCommands.split_segments(command.to_s)
55
+ return false if segments.nil? || segments.empty?
56
+
57
+ segments.all? { |segment| segment_allowed?(segment, entries) }
58
+ end
17
59
 
18
- @allowlist.any? do |allowed|
19
- command.strip.start_with?(allowed.strip)
60
+ private
61
+
62
+ # Each allowlist entry as its token list (e.g. "bundle exec rspec" ->
63
+ # %w[bundle exec rspec]). Empty / blank entries are dropped so a stray ""
64
+ # in the config can't match every command.
65
+ def allowlist_token_lists
66
+ @allowlist.filter_map do |entry|
67
+ tokens = Shellwords.split(entry.to_s)
68
+ tokens unless tokens.empty?
69
+ rescue ArgumentError
70
+ nil # an entry that won't tokenize (unbalanced quote) can't match
20
71
  end
21
72
  end
73
+
74
+ # A single chain segment is allowed when its leading tokens exactly match
75
+ # some allowlist entry's tokens AND it carries no write/exec flag that
76
+ # would turn a matched read-capable head into an arbitrary write or
77
+ # execution. Matching on the token list (not the raw string) makes it
78
+ # boundary-safe: `git status` matches `git status -s` but never
79
+ # `git statusxyz`.
80
+ #
81
+ # Flag vetting reuses ReadonlyCommands' dangerous-flag logic so the
82
+ # allowlist and the read-only auto-allow agree on what `git --output`,
83
+ # `find -exec/-delete/-fprintf`, `date -s`, `tree -o` mean: a head being
84
+ # on the allowlist pre-approves the COMMAND, never a smuggled output/exec
85
+ # flag (SEC-1 — `git diff --output FILE` arbitrary write).
86
+ def segment_allowed?(segment, entries)
87
+ tokens = Shellwords.split(segment)
88
+ return false if tokens.empty?
89
+
90
+ matched = entries.any? { |entry_tokens| tokens.first(entry_tokens.length) == entry_tokens }
91
+ return false unless matched
92
+
93
+ !ReadonlyCommands.dangerous_flags?(tokens)
94
+ rescue ArgumentError
95
+ false # unbalanced quotes etc. — fall through to the prompt
96
+ end
22
97
  end
23
98
  end
24
99
  end
@@ -4,15 +4,34 @@ module Rubino
4
4
  module Security
5
5
  # Detects when the agent enters a doom loop - repeatedly calling
6
6
  # the same tool with identical arguments without progress.
7
+ #
8
+ # Two dimensions, both config-driven (Hermes tool_guardrails alignment,
9
+ # #414):
10
+ # - threshold: how many identical consecutive calls trip detection
11
+ # (default 5; Hermes grades 5-8). The old default was 3, which hard-
12
+ # denied a legitimate 3rd retry of an idempotent read.
13
+ # - hard_stop: when true, a tripped detector means BLOCK (the policy
14
+ # returns :deny). When false (the default) it WARNS but allows — the
15
+ # policy surfaces a one-time warning to the model and lets the call run.
7
16
  class DoomLoopDetector
8
- DEFAULT_THRESHOLD = 3
17
+ DEFAULT_THRESHOLD = 5
9
18
 
10
- def initialize(threshold: DEFAULT_THRESHOLD)
19
+ attr_reader :threshold
20
+
21
+ def initialize(threshold: DEFAULT_THRESHOLD, hard_stop: false)
11
22
  @threshold = threshold
23
+ @hard_stop = hard_stop
12
24
  @history = []
13
25
  end
14
26
 
27
+ # True when the detector is configured to BLOCK on detection (vs. warn).
28
+ def hard_stop?
29
+ @hard_stop == true
30
+ end
31
+
15
32
  # Records a tool call and returns true if a doom loop is detected
33
+ # (the last `threshold` calls are identical). Detection is independent
34
+ # of hard_stop — the caller decides whether a hit blocks or only warns.
16
35
  def record(tool_name:, arguments:)
17
36
  signature = generate_signature(tool_name, arguments)
18
37
  @history << signature