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
@@ -4,6 +4,21 @@ module Rubino
4
4
  module Security
5
5
  # Hardline (unconditional) blocklist — a floor BELOW yolo.
6
6
  #
7
+ # SCOPE: this is a best-effort anti-INCIDENT circuit-breaker, NOT an anti-
8
+ # adversary boundary. It stops the agent (or a careless user) from running an
9
+ # unrecoverable command via --yolo, INCLUDING the accidental/model-emitted
10
+ # indirection forms that pad real commands — `echo $(rm -rf /)`, a backticked
11
+ # `rm -rf /`, a `{ rm -rf /; }` brace-group. It is NOT a sandbox: a determined
12
+ # adversary with shell access can always evade a regex floor (base64, here-
13
+ # docs, an env-var/here-string built at runtime, a written-then-run script).
14
+ # The real containment boundary is the deferred OS-level sandbox (#290).
15
+ # Within that anti-incident scope we canonicalize aggressively — collapsing
16
+ # trivial evasions (quoting, trailing slashes, path-equivalents, ${HOME}) AND
17
+ # UNWRAPPING command substitution / backticks / brace-and-subshell groups so
18
+ # the floor patterns match the INNER command — rather than widening the
19
+ # blocklist patterns to chase each wrapper (whack-a-mole on a best-effort
20
+ # string blocklist buys little; the boundary is #290, not the regex) (#325).
21
+ #
7
22
  # Commands so catastrophic they must NEVER run via the agent, regardless
8
23
  # of --yolo, skip-approvals mode, a permissions:allow rule, or a
9
24
  # command_allowlist entry. Opting into yolo is the user trusting the agent
@@ -58,21 +73,46 @@ module Rubino
58
73
  [/#{CMDPOS}telinit\s+[06]\b/, "telinit 0/6 (shutdown/reboot)"]
59
74
  ].freeze
60
75
 
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/
76
+ # sudo -S / sudo --stdin without a configured SUDO_PASSWORD is the model
77
+ # piping a *guessed* password via stdin — a brute-force vector.
78
+ # Unconditional block. Mirrors approval.py:_check_sudo_stdin_guard (:255).
79
+ #
80
+ # CASE-SENSITIVE on purpose: `-S` (capital — read the password from STDIN)
81
+ # is the dangerous form this guard owns; `-s` (lowercase — run $SHELL) is a
82
+ # DIFFERENT flag and must NOT be flagged here (sudo's own privilege-flag
83
+ # handling lives in DangerousPatterns, not the hardline floor). The
84
+ # previous `-s\b` regex relied on #normalize lowercasing the command, which
85
+ # both over-matched `sudo -s` AND failed to anchor on the real intent — and
86
+ # it missed the `--stdin` long form entirely. We now match against a
87
+ # case-PRESERVED form (#sudo_stdin?): `-S` and combined short clusters
88
+ # ending in S (`-kS`, `-nS`), plus the GNU `--stdin` long form.
89
+ SUDO_STDIN_RE = /(?:^|[;&|`\n]|&&|\|\||\$\()\s*sudo\s+(?:-[a-zA-Z]*S(?![a-zA-Z])|--stdin\b)/
65
90
 
66
91
  module_function
67
92
 
68
93
  # Returns [true, description] when the command hits the hardline floor
69
94
  # (a HARDLINE_PATTERN or the sudo-stdin guard), else [false, nil].
95
+ #
96
+ # We match the patterns against TWO forms and OR the results: the raw
97
+ # whitespace/case-normalized string, and a canonicalized form that strips
98
+ # quoting, expands $HOME, collapses path-equivalents, and UNWRAPS command
99
+ # substitution / backticks / brace-and-subshell groups (see #canonicalize).
100
+ # Canonicalization closes the trivial bypasses (rm -rf '/', /usr/, ${HOME},
101
+ # //, /./) and the indirection bypasses ($(rm -rf /), `rm -rf /`,
102
+ # { rm -rf /; }); matching the raw form too is fail-open insurance for the
103
+ # rare case where canonicalization rewrites a separator/redirect out of a
104
+ # match (an anti-incident guard should never become LESS strict than before).
70
105
  def detect(command)
71
106
  normalized = normalize(command)
107
+ canonical = canonicalize(normalized)
72
108
  HARDLINE_PATTERNS.each do |regex, description|
73
- return [true, description] if normalized.match?(regex)
109
+ return [true, description] if normalized.match?(regex) || canonical.match?(regex)
74
110
  end
75
- return [true, "sudo password guessing via stdin (sudo -S)"] if sudo_stdin?(normalized)
111
+ # The sudo-stdin guard is CASE-SENSITIVE (`-S` ≠ `-s`), so it must NOT see
112
+ # the lowercased normalized/canonical forms. Match it against a
113
+ # whitespace-normalized but case-PRESERVED form of the raw command.
114
+ sudo_hit = sudo_stdin?(normalize_case_preserving(command))
115
+ return [true, "sudo password guessing via stdin (sudo -S)"] if sudo_hit
76
116
 
77
117
  [false, nil]
78
118
  end
@@ -84,21 +124,154 @@ module Rubino
84
124
  blocked ? description : nil
85
125
  end
86
126
 
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)
127
+ # sudo -S / --stdin only fires the guard when no SUDO_PASSWORD is
128
+ # configured — with one set, an internal transform legitimately injects
129
+ # -S elsewhere. The input MUST be case-preserved (see #detect): the regex
130
+ # discriminates `-S` from `-s` on case.
131
+ def sudo_stdin?(case_preserved)
90
132
  return false if ENV.key?("SUDO_PASSWORD")
91
133
 
92
- normalized.match?(SUDO_STDIN_RE)
134
+ case_preserved.match?(SUDO_STDIN_RE)
93
135
  end
94
136
 
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.
137
+ # Minimal normalization: strip shell line-continuations, collapse runs of
138
+ # spaces/tabs (newlines kept so the command-separator anchors still fire),
139
+ # trim, and lowercase so trivial obfuscation (extra spaces, case) doesn't
140
+ # slip through. Deliberately NOT a full ANSI/Unicode normalizer —
141
+ # over-engineering for the hardline floor.
142
+ #
143
+ # Line-continuation strip (#348): a backslash immediately before a newline
144
+ # is a shell line-continuation — the two characters fold the next line onto
145
+ # the current one with NO intervening character. The shell deletes the
146
+ # `\<newline>` pair entirely; it does NOT insert a space. Pre-fix we replaced
147
+ # it with a SPACE, so `rm -r\<newline>f /` became `rm -r f /` — the `-r` and
148
+ # `f` split into two tokens and the `\brm\s+(?:-\S*\s+)*/` pattern (which
149
+ # needs the flags glued) MISSED it, letting an unrecoverable `rm -rf /` past
150
+ # the floor (#348 residual). Replace the continuation with the EMPTY string
151
+ # so `rm -r\<newline>f /` folds to `rm -rf /` exactly as the shell sees it.
152
+ # (Trailing whitespace after the backslash is NOT part of the continuation
153
+ # and is left for the space-collapse below.)
100
154
  def normalize(command)
101
- command.to_s.gsub(/[ \t]+/, " ").strip.downcase
155
+ normalize_case_preserving(command).downcase
156
+ end
157
+
158
+ # Same whitespace normalization as #normalize (line-continuation strip,
159
+ # space/tab collapse, trim) but WITHOUT lowercasing. Used only by the
160
+ # case-sensitive sudo-stdin guard so `-S` (stdin password) stays
161
+ # distinguishable from `-s` (start shell).
162
+ def normalize_case_preserving(command)
163
+ joined = command.to_s.gsub(/\\\r?\n/, "")
164
+ joined.gsub(/[ \t]+/, " ").strip
165
+ end
166
+
167
+ # Canonicalize the (already normalized) command so common, trivial
168
+ # evasions of the hardline patterns collapse onto the bare forms the
169
+ # patterns expect. This is the #325 hardening: instead of growing the
170
+ # pattern list to chase each quoting/path-equivalent variant, we normalize
171
+ # the INPUT the patterns see. Steps, per token:
172
+ # 1. Shell-word split (Shellwords) — strips quotes so '/' "/" '/usr'
173
+ # collapse to /, /usr. Unbalanced quotes raise ArgumentError; we then
174
+ # FALL BACK to the raw normalized string (fail-open — never raise out
175
+ # of a security check).
176
+ # 2. Expand a TINY fixed env set ($HOME, ${HOME}, "$HOME", $home, ${home})
177
+ # to ~ so the home-directory pattern fires on the brace/quote forms
178
+ # the (?:~|\$home) regex misses.
179
+ # 3. For path-shaped tokens (start with / or ~), Pathname#cleanpath
180
+ # (pure-string, no FS touch) collapses /usr/ -> /usr, // -> /,
181
+ # /. -> /, /./ -> /, /home/../ -> / .
182
+ # Re-join with single spaces and append a trailing space so a token-final
183
+ # `/` still satisfies the patterns' (?:\s|$) anchor.
184
+ def canonicalize(normalized)
185
+ require "shellwords"
186
+ require "pathname"
187
+ normalized = expand_word_splits(normalized)
188
+ normalized = unwrap_substitutions(normalized)
189
+ tokens = shell_split(normalized)
190
+ return normalized if tokens.nil? # unbalanced quotes: fail open to raw
191
+
192
+ cleaned = tokens.map { |tok| clean_token(tok) }
193
+ "#{cleaned.join(" ")} "
194
+ end
195
+
196
+ # Unwrap command-substitution / backtick / brace-and-subshell indirection so
197
+ # the floor patterns see the INNER command at command position (#325 gap).
198
+ # A model or a careless user can pad a catastrophic command with a wrapper
199
+ # the rm/dd/mkfs patterns don't anchor on — `$(rm -rf /)`, `` `rm -rf /` ``,
200
+ # `{ rm -rf /; }`, `echo $(rm -rf /*)`. The wrapper merely RUNS the inner
201
+ # text, so unwrapping it to bare text is faithful to what the shell executes.
202
+ #
203
+ # We turn each wrapper delimiter into a space, which promotes the inner
204
+ # command to the top level of the canonical string. Crucially we DO NOT
205
+ # touch `${...}` PARAMETER expansion (handled by #expand_word_splits /
206
+ # #clean_token) — only the `$(` COMMAND-substitution opener — so `${HOME}`
207
+ # and `${IFS}` keep their meaning. Applied repeatedly (capped) so nested
208
+ # `$(echo $(rm -rf /))` flattens too. Fail-open: a runaway input just stops
209
+ # after the cap and matching proceeds on whatever it reached.
210
+ def unwrap_substitutions(text)
211
+ 5.times do
212
+ before = text
213
+ # $( ... ) command substitution. `$(` opener (NOT `${`), and bare
214
+ # `)`/`(` subshell parens -> spaces. The negative lookbehind keeps
215
+ # `${...}` intact while still splitting `$(`.
216
+ text = text.gsub("$(", " ").gsub(/(?<!\$)[()]/, " ")
217
+ # backtick command substitution -> spaces (open and close).
218
+ text = text.gsub("`", " ")
219
+ # `{ ... ; }` brace group: a brace GROUP delimiter is a `{` followed by
220
+ # whitespace and a closing `}` at word boundary, plus the command-
221
+ # terminating `;` — all separators, not part of the command. Map them to
222
+ # spaces so the inner `rm -rf /` lands at command position. We DELIBERATELY
223
+ # do NOT touch a `{`/`}` that is part of `${...}` parameter expansion
224
+ # (no surrounding space) so `${HOME}`/`${IFS}` keep their meaning.
225
+ text = text.gsub(/(?<=\s|\A){\s/, " ").gsub(/(?<=\s)}/, " ")
226
+ text = text.gsub(";", " ")
227
+ break if text == before
228
+ end
229
+ text
230
+ end
231
+
232
+ # #348 follow-ups, applied BEFORE shell-splitting so the substituted text is
233
+ # re-tokenized into the bare `rm -rf /` form the patterns expect:
234
+ # * ${IFS} word-splitting: `rm${IFS}-rf${IFS}/` joins rm to / with no
235
+ # real whitespace, so Shellwords sees ONE token `rm-rf/`. Replace any
236
+ # ${IFS} / $IFS occurrence with a space so the shell's own field-split
237
+ # is reproduced. (lowercased input -> ${ifs}/$ifs.)
238
+ # * ${IFS:0:1} substring form (#348 residual): `${IFS:OFFSET:LENGTH}`
239
+ # takes a slice of $IFS — `${IFS:0:1}` is its first char (a space). It is
240
+ # the SAME field-split trick as ${IFS}; `rm${IFS:0:1}-rf${IFS:0:1}/` must
241
+ # also collapse to spaces. Match the brace-with-offset form too.
242
+ # * ${VAR:-/} / ${VAR:=/} param-default (#348 residual): the `:-`/`:=`
243
+ # default is used when VAR is unset/empty. The pre-fix only handled the
244
+ # literal name `home`; ANY unset varname works (`${X:-/}`, `${FOO:-/}`),
245
+ # so an attacker just picks a name that is unset and the default `/`
246
+ # expands to the root filesystem. Collapse `${<anyname>:-VALUE}` /
247
+ # `${<anyname>:=VALUE}` to its default VALUE so the root/home pattern
248
+ # fires regardless of the chosen variable name.
249
+ def expand_word_splits(text)
250
+ # ${ifs}, $ifs, and the ${ifs:OFFSET:LEN} substring form all -> a space.
251
+ out = text.gsub(/\$\{ifs(?::\d+(?::\d+)?)?\}|\$ifs\b/, " ")
252
+ # ${VAR:-VALUE} / ${VAR:=VALUE} -> VALUE (the default the shell uses when
253
+ # VAR is unset/empty), for ANY variable name. Captures the default path so
254
+ # `${x:-/}` becomes `/` and the root-filesystem pattern matches.
255
+ out.gsub(/\$\{[a-z_][a-z0-9_]*:[-=]([^}]*)\}/, '\1')
256
+ end
257
+
258
+ # Shell-word split, or nil on unbalanced quotes (caller falls back to raw).
259
+ def shell_split(normalized)
260
+ Shellwords.split(normalized)
261
+ rescue ArgumentError
262
+ nil
263
+ end
264
+
265
+ # Expand the tiny HOME env set, then cleanpath absolute-path tokens.
266
+ def clean_token(tok)
267
+ # $HOME / ${HOME} / $home / ${home} -> ~ (Shellwords already stripped the
268
+ # surrounding quotes of "$HOME"). Only when the token IS (or starts) the
269
+ # HOME ref, so we don't rewrite an unrelated $homedir.
270
+ tok = tok.sub(%r{\A\$\{?home\}?(?=\z|/)}, "~")
271
+ return tok unless tok.start_with?("/")
272
+
273
+ # Pure-string path cleanup: /usr/ -> /usr, // -> /, /. -> /, /home/../ -> /.
274
+ Pathname.new(tok).cleanpath.to_s
102
275
  end
103
276
  end
104
277
  end
@@ -21,17 +21,40 @@ module Rubino
21
21
  end
22
22
 
23
23
  # Returns the action for a given tool call description
24
- # Returns :allow, :ask, or :deny
24
+ # Returns :allow, :ask, or :deny (nil when no rule matches).
25
+ #
26
+ # DENY ALWAYS WINS (the documented permissions invariant). Resolution is
27
+ # NOT a plain first-hit on the specificity-sorted list — that let a longer,
28
+ # more specific :allow outrank a shorter overlapping :deny (e.g.
29
+ # "shell git push" => deny vs. "shell git push --force-with-lease …" =>
30
+ # allow), silently swallowing the deny. Instead we resolve in two passes
31
+ # over ALL matching rules:
32
+ #
33
+ # 1. If ANY matching rule is :deny, the result is :deny — regardless of
34
+ # a longer overlapping :allow/:ask. (deny wins ACROSS verdict classes)
35
+ # 2. Otherwise the FIRST (= longest/most-specific, per #parse_rules)
36
+ # matching :allow/:ask wins. (longest-match preserved WITHIN a class)
37
+ #
38
+ # The hardline floor is a separate, earlier layer (ApprovalPolicy step 1)
39
+ # and is unaffected.
25
40
  def match(tool_name, command_or_args = nil)
26
41
  full_string = [tool_name, command_or_args].compact.join(" ")
27
42
 
28
- # Check rules from most specific to least specific
43
+ first_non_deny = nil
29
44
  @rules.each do |pattern, action|
30
- return action.to_sym if matches_pattern?(full_string, pattern)
45
+ next unless matches_pattern?(full_string, pattern)
46
+
47
+ sym = action.to_sym
48
+ # Pass 1: a deny short-circuits everything — deny always wins.
49
+ return :deny if sym == :deny
50
+
51
+ # Pass 2 (deferred): remember the first (most-specific) allow/ask, but
52
+ # keep scanning in case a shorter overlapping deny is still ahead.
53
+ first_non_deny ||= sym
31
54
  end
32
55
 
33
- # Default: no explicit match
34
- nil
56
+ # No matching deny: the longest/most-specific allow/ask (or nil).
57
+ first_non_deny
35
58
  end
36
59
 
37
60
  # Returns true if the pattern matches the input
@@ -92,20 +92,25 @@ module Rubino
92
92
  end
93
93
 
94
94
  # Leading safe-token run of a plain command:
95
- # "git status" -> "git"
95
+ # "git status" -> "git status"
96
96
  # "bundle exec rspec" -> "bundle exec"
97
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.
98
+ # A wrapper command (bundle/npm/...) keeps its declared verb (exec/run) so
99
+ # distinct wrapped tools don't collapse into one rule. `git` keeps its
100
+ # READ-ONLY subcommand (`git status` not bare `git`) and refuses to derive
101
+ # a prefix at all for any non-read-only git verb: a bare `git` prefix
102
+ # persisted to the allowlist would pre-approve `git apply`,
103
+ # `git -c alias.x=!cmd x`, ... = RCE/arbitrary-write (SEC-R2-1). For a
104
+ # plain command the head alone is the prefix. The run stops at the first
105
+ # flag or argument-shaped token, mirroring CommandAllowlist's match.
102
106
  def command_prefix(command)
103
107
  tokens = command.to_s.strip.split(/\s+/)
104
108
  return "" if tokens.empty?
105
109
 
106
110
  head = tokens.first
107
- prefix = [head]
111
+ return git_prefix(tokens) if head == "git"
108
112
 
113
+ prefix = [head]
109
114
  if WRAPPERS.key?(head)
110
115
  verb = tokens[1]
111
116
  prefix << verb if verb && plain_word?(verb) && WRAPPERS[head].include?(verb)
@@ -114,6 +119,20 @@ module Rubino
114
119
  prefix.join(" ")
115
120
  end
116
121
 
122
+ # The persistable prefix for a git command: `git <subcommand>` ONLY when
123
+ # the subcommand is provably read-only (no global flags before it, a
124
+ # read-only verb). Anything else — a bare `git`, a global flag like `-c`,
125
+ # a mutating/code-loading verb (apply/am/push/...) — returns "" so the
126
+ # caller offers no "always_prefix" choice and the broad rule is never
127
+ # persisted. The narrow exact-command path still remains available.
128
+ def git_prefix(tokens)
129
+ sub = tokens[1]
130
+ return "" if sub.nil? || sub.start_with?("-")
131
+ return "" unless Security::ReadonlyCommands::GIT_READONLY_SUBCOMMANDS.include?(sub)
132
+
133
+ "git #{sub}"
134
+ end
135
+
117
136
  # A "plain word" is a bare token: not a flag, no path/assignment/glob
118
137
  # punctuation — the shape that can safely extend a prefix.
119
138
  def plain_word?(token)
@@ -45,13 +45,32 @@ module Rubino
45
45
  ].freeze
46
46
 
47
47
  # Mutating/executing flags that disqualify an otherwise-safe head.
48
- # Matched as exact token or `flag=value`.
48
+ # Matched as exact token or `flag=value`. Heads that read by default but
49
+ # gain a WRITE or an EXEC through one of these flags: an allowlisted entry
50
+ # for the head pre-approves the read, never the smuggled write/exec.
51
+ # sort -o FILE / --output=FILE arbitrary write (SEC-R2-2)
52
+ # (sed/tar/tee/awk/... are handled by CODE_EXEC_HEADS below — their
53
+ # argument is itself a program / they pipe to a shell, so no flag list can
54
+ # make an arbitrary-arg invocation safe.)
49
55
  FORBIDDEN_FLAGS = {
50
56
  "find" => %w[-exec -execdir -ok -okdir -delete -fprintf -fprint -fprint0 -fls],
51
57
  "date" => %w[-s --set],
52
- "tree" => %w[-o]
58
+ "tree" => %w[-o],
59
+ "sort" => %w[-o --output]
53
60
  }.freeze
54
61
 
62
+ # Heads whose very PURPOSE is to run arbitrary code (or whose argument is
63
+ # a program that can spawn a shell), so no flag inspection can make an
64
+ # arbitrary-arg invocation provably safe. An allowlist entry for one of
65
+ # these is DENIED auto-approval outright (default-deny): being on the
66
+ # allowlist must never pre-approve `awk 'BEGIN{system("…")}'`,
67
+ # `sed -n '…e cmd'`, `perl -e …`, `tee FILE`, etc. (SEC-R2-2). This is the
68
+ # convenience-layer floor; these still run after an explicit prompt.
69
+ CODE_EXEC_HEADS = %w[
70
+ awk gawk mawk sed perl python python2 python3 ruby node php pwsh
71
+ bash sh zsh ksh dash env xargs eval source tee tar dd
72
+ ].freeze
73
+
55
74
  # Leading `FOO=bar cmd` environment assignment — rejected, not stripped:
56
75
  # an assignment can change what the command resolves to (PATH=...) or
57
76
  # how it behaves, so it is never "provably read-only".
@@ -179,15 +198,136 @@ module Rubino
179
198
  end
180
199
  end
181
200
 
201
+ # Heads that are otherwise allowlistable but can still WRITE or EXEC
202
+ # through trailing flags (git --output, find -exec/-delete/-fprintf,
203
+ # date -s, tree -o, sort -o, tar --to-command). The user command allowlist
204
+ # reuses this to vet the flags of a matched entry, so an allowlisted head
205
+ # (e.g. `git diff`) can never smuggle an arbitrary write via `--output`.
206
+ FLAG_VETTED_HEADS = (["git"] + FORBIDDEN_FLAGS.keys).freeze
207
+
208
+ # True when `tokens` (a single already-split, non-chained segment whose
209
+ # head matched a user allowlist entry) must NOT be auto-approved because
210
+ # the head can WRITE or EXEC arbitrary code with these arguments. An
211
+ # allowlist entry pre-approves the EXACT read-only intent of a head, never
212
+ # a smuggled write/exec form. Pure inspection; it does NOT require the
213
+ # command to be read-only overall (an allowlist entry is user-chosen), it
214
+ # only rejects the forms that turn a read into a write/exec.
215
+ #
216
+ # - CODE_EXEC_HEADS (awk/sed/perl/python/tar/tee/xargs/...) are
217
+ # default-denied outright: their argument IS a program (or pipes to a
218
+ # shell), so `awk 'BEGIN{system("…")}'`, `sed -e '…'`, `tar
219
+ # --to-command=sh` can't be made provably safe by flag inspection
220
+ # (SEC-R2-2);
221
+ # - git is screened for BOTH global flags before the subcommand
222
+ # (`-c alias.X=!cmd`, `-c core.sshCommand=…`, `-C dir`, `--exec-path`)
223
+ # and a code-loading/writing subcommand (`apply`, `am`, hooks, …) and
224
+ # the --output/-o write flag (SEC-R2-1);
225
+ # - the remaining FORBIDDEN_FLAGS heads (find/date/tree/sort/...) are
226
+ # screened for their specific write/exec flags.
227
+ def dangerous_flags?(tokens)
228
+ head = tokens.first
229
+ return true if CODE_EXEC_HEADS.include?(head)
230
+ return false unless FLAG_VETTED_HEADS.include?(head)
231
+
232
+ if head == "git"
233
+ dangerous_git?(tokens)
234
+ else
235
+ !safe_flags?(head, tokens)
236
+ end
237
+ end
238
+
239
+ # Git GLOBAL flags (between `git` and the subcommand) that load or run
240
+ # arbitrary code, and the dangerous subcommands an allowlisted bare `git`
241
+ # would otherwise pre-approve. None of these belong to a read-only git
242
+ # intent, so an allowlisted git head carrying any of them is rejected.
243
+ #
244
+ # -c <name>=<val> / -c<name>=<val> sets config for this invocation; the
245
+ # load-bearing ones are alias.* (a `!cmd` alias = RCE), core.sshCommand,
246
+ # core.pager, core.editor, core.hooksPath, core.fsmonitor,
247
+ # uploadpack/receivepack.* — all run a command. We reject -c entirely
248
+ # for an auto-approved git: a read-only git never needs per-call config.
249
+ # --config-env=<name>=<envvar> sets config like -c, sourcing the
250
+ # VALUE from an environment variable (so `--config-env=alias.x=PWNVAR`
251
+ # with PWNVAR='!cmd' is the same RCE as `-c alias.x='!cmd'`). Rejected
252
+ # for the same reason as -c (SEC-R3-1).
253
+ # --attr-source=<tree> reads .gitattributes from an
254
+ # arbitrary tree-ish; not config, but never part of a read-only intent,
255
+ # so rejected too (SEC-R3-1).
256
+ # -C <path> / --exec-path[=path] changes the working dir / git's exec
257
+ # path (which can point git at attacker binaries).
258
+ GIT_GLOBAL_EXEC_FLAGS = %w[
259
+ -c --config-env --exec-path --git-dir --work-tree --namespace --attr-source -C
260
+ ].freeze
261
+
262
+ # Subcommands that mutate the working tree / apply attacker-supplied data /
263
+ # run hooks, never part of a read-only intent. An allowlisted git head must
264
+ # not pre-approve them even though they don't start with `-`.
265
+ GIT_DANGEROUS_SUBCOMMANDS = %w[
266
+ apply am rebase merge cherry-pick revert reset checkout switch restore
267
+ clean stash push pull fetch clone commit hook filter-branch
268
+ update-index update-ref symbolic-ref config send-email daemon
269
+ ].freeze
270
+
271
+ # True when a git invocation whose head matched an allowlist entry carries
272
+ # a code-loading global flag, a dangerous subcommand, or an output-writing
273
+ # flag. Scans the GLOBAL flag region (before the subcommand) AND the rest.
274
+ def dangerous_git?(tokens)
275
+ rest = tokens.drop(1)
276
+ # Global flag region: everything up to the first non-flag token (the
277
+ # subcommand). `-c name=val` / `-C path` may consume the next token as
278
+ # their value, so a value that happens to look like a subcommand isn't
279
+ # mistaken for one.
280
+ i = 0
281
+ while i < rest.length
282
+ tok = rest[i]
283
+ break unless tok.start_with?("-")
284
+
285
+ return true if git_global_exec_flag?(tok)
286
+
287
+ # -c / -C / --exec-path take a value as the NEXT token when not glued.
288
+ i += 1 if %w[-c -C].include?(tok) && !rest[i + 1].nil?
289
+ i += 1
290
+ end
291
+
292
+ sub = rest[i]
293
+ return true if sub && GIT_DANGEROUS_SUBCOMMANDS.include?(sub)
294
+
295
+ git_write_flag?(rest.drop(i + 1))
296
+ end
297
+
298
+ # A global-flag token matches when it is the exact flag (`-c`,
299
+ # `--exec-path`), its `flag=value` form (`--exec-path=/x`), or a glued
300
+ # short form (`-cNAME=VAL`, `-Cpath`).
301
+ def git_global_exec_flag?(tok)
302
+ GIT_GLOBAL_EXEC_FLAGS.any? do |f|
303
+ tok == f ||
304
+ tok.start_with?("#{f}=") ||
305
+ (f.length == 2 && f.start_with?("-") && !f.start_with?("--") && tok.start_with?(f) && tok.length > 2)
306
+ end
307
+ end
308
+
309
+ # Git flags that write the output to an arbitrary file:
310
+ # --output <file> / --output=<file> (git diff/log/show/format-patch)
311
+ # -o <file> / -o<file> (short form, git log/format-patch)
312
+ # `-O<orderfile>` reads an orderfile (no write) but is rejected too, so a
313
+ # short-flag write form can never slip through ambiguity. Matched on the
314
+ # whole rest of the segment (token or `flag=value` / glued `-oFILE`).
315
+ def git_write_flag?(rest)
316
+ rest.any? do |t|
317
+ t == "--output" || t.start_with?("--output=", "-o", "-O")
318
+ end
319
+ end
320
+
182
321
  # 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.
322
+ # falls to the prompt), never an output-writing flag (git log/diff/show
323
+ # can write a file with --output/-o), branch/remote in their pure
324
+ # listing forms only.
185
325
  def safe_git?(tokens)
186
326
  sub = tokens[1]
187
327
  return false if sub.nil? || sub.start_with?("-")
188
328
 
189
329
  rest = tokens.drop(2)
190
- return false if rest.any? { |t| t == "--output" || t.start_with?("--output=") }
330
+ return false if git_write_flag?(rest)
191
331
 
192
332
  case sub
193
333
  when *GIT_READONLY_SUBCOMMANDS then true
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubino
4
+ module Security
5
+ # ONE "is this a secret/credential path?" predicate, shared by the tool
6
+ # layer (read/grep/glob refuse to leak; write/edit refuse to clobber) and
7
+ # the approval layer (Security::ApprovalPolicy#decide → :ask). Previously
8
+ # the read side and the write side carried two parallel denylists; the
9
+ # maintainer decision (#446) is that reading OR writing a secret both
10
+ # require the SAME explicit user approval over the SAME set — so the set
11
+ # and the predicate live here, once.
12
+ #
13
+ # The gate itself is in ApprovalPolicy/ToolExecutor (interactive →
14
+ # approval dropdown; approved → tool proceeds; denied → refused; headless →
15
+ # fails closed). This module is pure detection — it never prompts, blocks,
16
+ # or touches IO beyond symlink resolution.
17
+ module SecretPath
18
+ module_function
19
+
20
+ # Credential/secret BASENAMES, in any directory.
21
+ BASENAME_RE = /
22
+ \A\.env(\..+)?\z | \A\.envrc\z |
23
+ \A\.netrc\z | \A\.pgpass\z | \A\.npmrc\z | \A\.pypirc\z |
24
+ \A\.git-credentials\z |
25
+ \A\.bashrc\z | \A\.zshrc\z | \A\.profile\z | \A\.bash_profile\z | \A\.zprofile\z
26
+ /x
27
+
28
+ # Home-relative credential subtrees (resolved against $HOME).
29
+ HOME_PREFIXES = [
30
+ ".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure",
31
+ ".config/gh", ".config/gcloud"
32
+ ].freeze
33
+
34
+ # Absolute system paths / prefixes.
35
+ SYSTEM_PATHS = ["/etc/sudoers", "/etc/passwd", "/etc/shadow"].freeze
36
+ SYSTEM_PREFIXES = ["/etc/sudoers.d", "/etc/systemd"].freeze
37
+
38
+ # Returns the matched-secret category string (truthy) for a secret path,
39
+ # or nil for a normal file. `path` may be relative or absolute; it is
40
+ # resolved through every symlink first so an in-workspace link to ~/.ssh
41
+ # can't slip past the basename/prefix checks.
42
+ def category(path)
43
+ base = File.basename(path.to_s)
44
+ target = canonical_path(path) || File.expand_path(path.to_s)
45
+
46
+ return "credential file (#{base})" if base.match?(BASENAME_RE)
47
+ if (cat = denied_path_category(target, base))
48
+ return cat
49
+ end
50
+
51
+ agent_home_category(path, base, target)
52
+ end
53
+
54
+ # True when `path` is a secret. Thin boolean wrapper over #category.
55
+ def secret?(path)
56
+ !category(path).nil?
57
+ end
58
+
59
+ # Absolute-path / prefix matches (SSH keys, cloud creds, /etc system
60
+ # files), compared against the symlink-resolved target.
61
+ def denied_path_category(target, base)
62
+ home = File.expand_path("~")
63
+ HOME_PREFIXES.each do |rel|
64
+ return "credential directory (~/#{rel})" if under_path?(target, File.join(home, rel))
65
+ end
66
+ return "system file (#{base})" if SYSTEM_PATHS.include?(target)
67
+
68
+ SYSTEM_PREFIXES.each do |prefix|
69
+ return "system path (#{prefix})" if under_path?(target, prefix)
70
+ end
71
+ nil
72
+ end
73
+
74
+ # Agent-home (~/.rubino) auth/secret material: the home .env, the token
75
+ # store sqlite DB, *oauth* files, mcp-tokens/, and *.key/*.pem material.
76
+ def agent_home_category(path, base, target)
77
+ return unless under_agent_home?(path)
78
+
79
+ lower = target.downcase
80
+ return unless base == ".env" || base.match?(BASENAME_RE) ||
81
+ base == "rubino.sqlite3" || base.end_with?(".sqlite3") ||
82
+ lower.include?("oauth") || lower.include?("/mcp-tokens/") ||
83
+ base.end_with?(".key") || base.end_with?(".pem")
84
+
85
+ "agent-home secret (#{base})"
86
+ end
87
+
88
+ # True when +target+ is +root+ itself or sits under it.
89
+ def under_path?(target, root)
90
+ target == root || target.start_with?("#{root}#{File::SEPARATOR}")
91
+ end
92
+
93
+ # Resolves `path` through every symlink to its canonical destination,
94
+ # re-joining the missing tail for a not-yet-created target. Mirrors
95
+ # Tools::Base#canonical_path so the write-creates-new-file flow resolves
96
+ # to the same place the tool will write.
97
+ def canonical_path(path)
98
+ return nil if path.nil? || path.to_s.empty?
99
+
100
+ expanded = File.expand_path(path.to_s)
101
+ return File.realpath(expanded) if File.exist?(expanded)
102
+
103
+ ancestor = expanded
104
+ tail = []
105
+ until File.exist?(ancestor)
106
+ parent = File.dirname(ancestor)
107
+ break if parent == ancestor
108
+
109
+ tail.unshift(File.basename(ancestor))
110
+ ancestor = parent
111
+ end
112
+ return nil unless File.exist?(ancestor)
113
+
114
+ File.join(File.realpath(ancestor), *tail)
115
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
116
+ nil
117
+ end
118
+
119
+ # True when `path` resolves under the Rubino home directory.
120
+ def under_agent_home?(path)
121
+ home = Rubino.home_path
122
+ return false if home.nil? || home.to_s.empty?
123
+
124
+ home_real = (File.realpath(home) if File.exist?(home)) || File.expand_path(home)
125
+ target_real = canonical_path(path)
126
+ return false unless target_real
127
+
128
+ target_real == home_real || target_real.start_with?("#{home_real}#{File::SEPARATOR}")
129
+ rescue StandardError
130
+ false
131
+ end
132
+ end
133
+ end
134
+ end