rubik 0.1.5 → 0.2.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/ATTRIBUTION.md +1 -1
  3. data/CHANGELOG.md +130 -0
  4. data/README.md +30 -17
  5. data/lib/rubik/ast/extraction_result.rb +37 -0
  6. data/lib/rubik/ast/extractor.rb +35 -9
  7. data/lib/rubik/ast/graph.rb +27 -1
  8. data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
  9. data/lib/rubik/ast/treesitter_extractor.rb +45 -17
  10. data/lib/rubik/buffer/apply_contract.rb +78 -0
  11. data/lib/rubik/buffer/manager.rb +61 -5
  12. data/lib/rubik/coeffects.rb +14 -15
  13. data/lib/rubik/concurrency/event_loop.rb +56 -44
  14. data/lib/rubik/concurrency/scheduler.rb +7 -24
  15. data/lib/rubik/config/defaults.rb +9 -6
  16. data/lib/rubik/config.rb +8 -0
  17. data/lib/rubik/context/git_lister.rb +6 -1
  18. data/lib/rubik/context/scanner.rb +15 -3
  19. data/lib/rubik/mcp/transport/stdio.rb +14 -11
  20. data/lib/rubik/orchestrator/step_executor.rb +9 -2
  21. data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
  22. data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
  23. data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
  24. data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
  25. data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
  26. data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
  27. data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
  28. data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
  29. data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
  30. data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
  31. data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
  32. data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
  33. data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
  34. data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
  35. data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
  36. data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
  37. data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
  38. data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
  39. data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
  40. data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
  41. data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
  42. data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
  43. data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
  44. data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
  45. data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
  46. data/lib/rubik/orchestrator/tool_history.rb +8 -3
  47. data/lib/rubik/platform/process.rb +74 -2
  48. data/lib/rubik/plugin/manager.rb +148 -71
  49. data/lib/rubik/plugin/package_inventory.rb +84 -0
  50. data/lib/rubik/plugin/requires_dag.rb +40 -0
  51. data/lib/rubik/plugin/storage.rb +100 -14
  52. data/lib/rubik/prompt/doctrine.rb +5 -3
  53. data/lib/rubik/repl/commands/buffer.rb +28 -18
  54. data/lib/rubik/repl/commands/subagents.rb +2 -0
  55. data/lib/rubik/repl/input_execution.rb +42 -0
  56. data/lib/rubik/repl/input_loop.rb +57 -7
  57. data/lib/rubik/repl/prompt_grace.rb +103 -0
  58. data/lib/rubik/runtime/repair_probe.rb +98 -0
  59. data/lib/rubik/security/blocked_guidance.rb +19 -0
  60. data/lib/rubik/security/guard.rb +115 -6
  61. data/lib/rubik/security/redactor.rb +7 -7
  62. data/lib/rubik/storage/transcript_indexer.rb +21 -11
  63. data/lib/rubik/tools/builtins/exec.rb +76 -28
  64. data/lib/rubik/tools/builtins/files.rb +6 -1
  65. data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
  66. data/lib/rubik/tools/builtins/subagent.rb +39 -31
  67. data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
  68. data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
  69. data/lib/rubik/tools/truncator.rb +19 -2
  70. data/lib/rubik/tools/uri_resolver.rb +84 -20
  71. data/lib/rubik/uris.rb +11 -0
  72. data/lib/rubik/version.rb +1 -1
  73. metadata +20 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../../buffer/apply_contract"
4
5
 
5
6
  module Rubik
6
7
  module REPL
@@ -51,27 +52,36 @@ module Rubik
51
52
 
52
53
  shell.register_command("apply", description: "Apply dirty buffer changes to disk") do |args, ctx|
53
54
  target_path = args.first
54
- if target_path && !target_path.empty?
55
- applied = ctx.buffer_manager.apply!(target_path)
56
- conflicts = ctx.buffer_manager.last_conflicts || []
57
- if applied.empty? && conflicts.empty?
58
- ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
55
+ begin
56
+ if target_path && !target_path.empty?
57
+ applied = ctx.buffer_manager.apply!(target_path)
58
+ conflicts = ctx.buffer_manager.last_conflicts || []
59
+ if applied.empty? && conflicts.empty?
60
+ ctx.out.puts "No unapplied buffer changes for '#{target_path}'."
61
+ else
62
+ ctx.sync_context_and_ast(applied) unless applied.empty?
63
+ ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
64
+ end
59
65
  else
60
- ctx.sync_context_and_ast(applied) unless applied.empty?
61
- ctx.out.puts "Applied changes to file: #{applied.first}" unless applied.empty?
66
+ applied = ctx.buffer_manager.apply!
67
+ conflicts = ctx.buffer_manager.last_conflicts || []
68
+ if applied.empty? && conflicts.empty?
69
+ ctx.out.puts "No unapplied buffer changes."
70
+ else
71
+ ctx.sync_context_and_ast(applied) unless applied.empty?
72
+ ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
73
+ end
62
74
  end
63
- else
64
- applied = ctx.buffer_manager.apply!
65
- conflicts = ctx.buffer_manager.last_conflicts || []
66
- if applied.empty? && conflicts.empty?
67
- ctx.out.puts "No unapplied buffer changes."
68
- else
69
- ctx.sync_context_and_ast(applied) unless applied.empty?
70
- ctx.out.puts "Applied changes to #{applied.size} file(s): #{applied.join(', ')}" unless applied.empty?
75
+ conflicts.each do |c|
76
+ ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
71
77
  end
72
- end
73
- conflicts.each do |c|
74
- ctx.out.puts "[CONFLICT] #{c}: disk content changed since last read — skipped to prevent data loss. Re-read the file to resolve."
78
+ rescue Rubik::Buffer::ApplyFailedError => e
79
+ # Partial apply (issue #146): the report names what DID reach
80
+ # disk before the failure — a mixed outcome must not be
81
+ # presented as a plain success or a plain failure.
82
+ lines = Rubik::Buffer::ApplyContract.failure_lines(e, sync: ->(paths) { ctx.sync_context_and_ast(paths) })
83
+ ctx.out.puts "❌ #{lines.first}"
84
+ lines[1..].each { |line| ctx.out.puts line }
75
85
  end
76
86
  end
77
87
 
@@ -94,6 +94,8 @@ module Rubik
94
94
  ctx.out.puts " Uptime: #{inst.uptime.round(2)}s"
95
95
  ctx.out.puts " Workspace: #{inst.workspace}"
96
96
  ctx.out.puts " Transcript: #{inst.transcript_uri}"
97
+ # Canonical read reference (issue #123): the path above is diagnostic.
98
+ ctx.out.puts " Read Ref: #{inst.transcript_ref}" if inst.respond_to?(:transcript_ref)
97
99
  ctx.out.puts " Last Error: #{inst.last_error}" if inst.last_error
98
100
  ctx.out.puts " Result: #{inst.result.to_s.slice(0, 150)}..." if inst.result
99
101
  end
@@ -148,6 +148,13 @@ module Rubik
148
148
  # an unbound read would mask the in-flight exception with a
149
149
  # NameError, breaking the "cleanup must never mask" contract.
150
150
  interrupted = false
151
+ # Task ownership (issue #125): this turn owns the subagents it spawns.
152
+ # The token is published for the turn's duration; ^C cancels it and the
153
+ # supervisor stops exactly the owned descendants (detached work and
154
+ # older turns' executions carry different tokens and survive).
155
+ task = Rubik::Orchestrator::TaskCancellation.new
156
+ @active_task = task
157
+ @runtime_manager&.provide(Rubik::Coeffects::TASK_CANCELLATION, task)
151
158
  # Plan 109 staleness rule (see execute_command): a real turn's
152
159
  # context already includes the idle-injected notices. The notice
153
160
  # turn itself drains first, so this clear is a no-op for it.
@@ -271,6 +278,10 @@ module Rubik
271
278
  # at route time), so draining loses nothing the model needed.
272
279
  clear_pending_wake_notices! if respond_to?(:clear_pending_wake_notices!)
273
280
  Rubik::REPL::InterruptHandler.restore_console_after_interrupt
281
+ # Propagate the cancellation to owned work (issue #125) and report the
282
+ # fact: with nothing owned running this reads as an interrupted wait,
283
+ # with owned descendants it reads as a cancelled task tree.
284
+ task_cancellation_lines(cancel_foreground_task!(task)).each { |line| @output.puts line }
274
285
  # Dedicated event type: restore_session must NOT hydrate this as a
275
286
  # dialogue turn (the user_turn recorded at method entry already
276
287
  # carries the question; a repl_command-style pair would duplicate it).
@@ -294,6 +305,7 @@ module Rubik
294
305
  record_failed_turn!(text, "failed", "#{e.class}: #{message}", orchestrator)
295
306
  ensure
296
307
  @active_loop = nil
308
+ @active_task = nil
297
309
  # Flush a half-streamed markdown tail so an interrupt never eats the last
298
310
  # line. Cleanup must never mask the in-flight exception (Interrupt, LLM
299
311
  # error): each step reports its own failure to stderr instead of raising.
@@ -320,6 +332,36 @@ module Rubik
320
332
  reap_dead_kernel_units! if respond_to?(:reap_dead_kernel_units!)
321
333
  end
322
334
 
335
+ # Foreground task cancellation (issue #125): cancel the turn's token and
336
+ # stop its owned descendants within the configured grace period. Returns
337
+ # the supervisor's fact report (nil when no supervisor is wired).
338
+ def cancel_foreground_task!(task)
339
+ supervisor = Rubik::Coeffects.get(@runtime_manager&.context, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
340
+ return nil unless task && supervisor.respond_to?(:cancel_owned)
341
+
342
+ supervisor.cancel_owned(task, timeout: @config.fetch("subagents.cancel_timeout", 2.0))
343
+ rescue StandardError => e
344
+ warn "[rubik] task cancellation failed: #{e.class}: #{e.message}"
345
+ nil
346
+ end
347
+
348
+ # The UI's two truths (issue #125): an interrupt with nothing owned is an
349
+ # interrupted WAIT; one with owned work is a cancelled TASK TREE — and
350
+ # remaining work is named, so an incomplete cleanup is never silent.
351
+ def task_cancellation_lines(outcome)
352
+ return ["⏸️ [Rubik] Bekleme kesildi (^C) — iptal edilecek bağlı alt ajan yok."] unless outcome&.any?
353
+
354
+ line = "🛑 [Rubik] Görev ağacı iptal edildi (^C): #{outcome.stopped.size} bağlı alt ajan durduruldu"
355
+ line << ", #{outcome.queued_cancelled.size} kuyruk işi iptal edildi" unless outcome.queued_cancelled.empty?
356
+ if outcome.complete?
357
+ line << "; temizlik tamamlandı."
358
+ else
359
+ remaining = outcome.remaining.map { |r| "#{r[:id]}:#{r[:state]}" }.join(", ")
360
+ line << "; temizlik TAMAMLANMADI — kalan: #{remaining}."
361
+ end
362
+ [line]
363
+ end
364
+
323
365
  # Shared spinner teardown. Cleanup must never mask the in-flight
324
366
  # exception (AGENTS.md §5): a spinner whose tick thread died holding
325
367
  # the mutex raises ThreadError from synchronize — report it to stderr
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "prompt_grace"
4
+
3
5
  module Rubik
4
6
  module REPL
5
7
  # Öneri 3 (Kademe 1): Shell'den ayrılan girdi döngüsü sorumluluğu.
@@ -18,6 +20,7 @@ module Rubik
18
20
 
19
21
  def run_interactive
20
22
  @running = true
23
+ @interactive_prompt_loop = true
21
24
  multiline_buffer = []
22
25
  heredoc_delimiter = nil
23
26
  last_line_at = nil
@@ -33,6 +36,7 @@ module Rubik
33
36
  end
34
37
  end
35
38
  ensure
39
+ @interactive_prompt_loop = false
36
40
  stop
37
41
  end
38
42
 
@@ -45,7 +49,7 @@ module Rubik
45
49
  text = read_paste_block
46
50
  return { stop: false, buffer: [], delimiter: nil } if text.strip.empty?
47
51
 
48
- execute_input(text)
52
+ submit_interactive_prompt(text)
49
53
  { stop: false, buffer: [], delimiter: nil }
50
54
  end
51
55
 
@@ -66,6 +70,7 @@ module Rubik
66
70
  flush_subagent_notices! if respond_to?(:flush_subagent_notices!)
67
71
  @terminal_guard&.flush_notices!
68
72
  current_prompt, boxed = prepare_prompt_line(!heredoc_delimiter.nil?)
73
+ restored_submission = !@prompt_restore.nil?
69
74
  line = safe_readline(current_prompt, add_history: false)
70
75
  @output.puts box_bottom_border if boxed
71
76
  return handle_eof(multiline_buffer, heredoc_delimiter) if line.nil? || line == "\x04" || line == "\x1a"
@@ -82,14 +87,14 @@ module Rubik
82
87
  # "instantly", so an ungated collector would swallow consecutive test
83
88
  # lines like "x = a << 1" + "exit" into one turn (seen in test_repl).
84
89
  now = paste_clock
85
- if paste_burst_eligible? && !line.strip.empty? && !heredoc_delimiter &&
90
+ if !restored_submission && paste_burst_eligible? && !line.strip.empty? && !heredoc_delimiter &&
86
91
  multiline_buffer.empty? &&
87
92
  (paste_line_fast?(last_line_at, now) || paste_pending?)
88
93
  return collect_paste_burst(line)
89
94
  end
90
95
 
91
96
  # Push non-empty command to history
92
- @reline::HISTORY.push(line) if !line.strip.empty? && line != begin
97
+ @reline::HISTORY.push(line) if !prompt_grace_interactive? && !line.strip.empty? && line != begin
93
98
  @reline::HISTORY.to_a.last
94
99
  rescue
95
100
  nil
@@ -152,13 +157,15 @@ module Rubik
152
157
  collector.call
153
158
  end
154
159
  text = lines.join("\n")
155
- execute_input(text)
160
+ submit_interactive_prompt(text)
156
161
  # Return the final line timestamp so a following paste burst chain
157
162
  # (copy #2 right after copy #1) is still detected as fast lines.
158
163
  { stop: false, buffer: [], delimiter: nil, last_line_at: last_at }
159
164
  end
160
165
 
161
166
  def safe_readline(prompt, add_history: false)
167
+ return read_restored_prompt(prompt) if @prompt_restore
168
+
162
169
  if @terminal_guard
163
170
  @terminal_guard.with_reading { @reline.readline(prompt, add_history) }
164
171
  else
@@ -179,7 +186,7 @@ module Rubik
179
186
  def process_heredoc_line(line, multiline_buffer, heredoc_delimiter)
180
187
  if line.strip == heredoc_delimiter
181
188
  full_text = multiline_buffer.join("\n")
182
- execute_input(full_text)
189
+ submit_interactive_prompt(full_text)
183
190
  { stop: false, buffer: [], delimiter: nil }
184
191
  else
185
192
  multiline_buffer << line
@@ -190,7 +197,7 @@ module Rubik
190
197
  def process_normal_line(line, _multiline_buffer)
191
198
  # Only trigger heredoc if line explicitly opens a multiline block (e.g. `<<EOF`, `!plan <<EOF`)
192
199
  # NEVER trigger on inline operators like `array << item` or `a << b`
193
- heredoc_match = line.match(/^\s*(?:[a-zA-Z0-9_!.-]+\s+)?<<[-~]?['"]?([A-Za-z0-9_]+)['"]?\s*$/)
200
+ heredoc_match = line.match(/\A[ \t]*(?:[a-zA-Z0-9_!.-]+[ \t]+)?<<[-~]?['"]?([A-Za-z0-9_]+)['"]?[ \t]*\z/)
194
201
  if heredoc_match && !line.strip.start_with?("#")
195
202
  delimiter = heredoc_match[1]
196
203
  prefix = line.sub(/<<[-~]?['"]?#{Regexp.escape(delimiter)}['"]?\s*$/, "").strip
@@ -200,7 +207,7 @@ module Rubik
200
207
  stop
201
208
  { stop: true, buffer: [], delimiter: nil }
202
209
  else
203
- execute_input(line)
210
+ submit_interactive_prompt(line)
204
211
  { stop: false, buffer: [], delimiter: nil }
205
212
  end
206
213
  end
@@ -208,6 +215,49 @@ module Rubik
208
215
  def process_multiline_or_line(line)
209
216
  execute_input(line)
210
217
  end
218
+
219
+ def prompt_grace_interactive?
220
+ @interactive_prompt_loop && !@replaying && interactive_input?
221
+ end
222
+
223
+ def submit_interactive_prompt(text)
224
+ return execute_input(text) unless prompt_grace_interactive?
225
+
226
+ if CommandParser.parse(text).type == :prompt
227
+ milliseconds = @config.fetch("ui.prompt_grace_window_ms", 1000)
228
+ outcome = PromptGrace.new(input: @input, output: @output,
229
+ context: @runtime_manager&.context).wait(milliseconds)
230
+ if outcome == :edit
231
+ @prompt_restore = text
232
+ return
233
+ end
234
+ return if outcome == :cancel
235
+ end
236
+ @reline::HISTORY.push(text) unless text.strip.empty? || @reline::HISTORY.to_a.last == text
237
+ execute_input(text)
238
+ end
239
+
240
+ def read_restored_prompt(prompt)
241
+ text = @prompt_restore
242
+ @prompt_restore = nil
243
+ previous = @reline.pre_input_hook
244
+ scope = @runtime_manager ? @runtime_manager.context.extend : Rubik::Runtime::Context.new
245
+ scope.on_dispose { @reline.pre_input_hook = previous }
246
+ @reline.pre_input_hook = lambda {
247
+ previous&.call
248
+ @reline.insert_text(text)
249
+ }
250
+ reader = -> { @reline.readmultiline(prompt, false) { true } }
251
+ if @terminal_guard
252
+ # A reactive wake must never inject Enter while Reline resets an
253
+ # apparently empty buffer, before the restoration hook fills it.
254
+ @terminal_guard.with_collecting { @terminal_guard.with_reading(&reader) }
255
+ else
256
+ reader.call
257
+ end
258
+ ensure
259
+ scope&.dispose
260
+ end
211
261
  end
212
262
  end
213
263
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "io/wait"
5
+ require_relative "../runtime/context"
6
+
7
+ module Rubik
8
+ module REPL
9
+ # Synchronous admission gate: no task, recorder entry or delayed callback
10
+ # exists until this returns :execute. A temporary context owns all terminal
11
+ # effects and is pruned from its parent on every exit.
12
+ class PromptGrace
13
+ ESCAPE_TIMEOUT = 0.03
14
+ STATUS = "Gönderiliyor… Esc: düzenlemeye dön"
15
+
16
+ def initialize(input:, output:, context: nil, clock: nil, reader: nil)
17
+ @input = input
18
+ @output = output
19
+ @context = context
20
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
21
+ @reader = reader || method(:read_byte)
22
+ end
23
+
24
+ def wait(milliseconds)
25
+ return :execute if milliseconds.zero?
26
+
27
+ scope = @context ? @context.extend : Rubik::Runtime::Context.new
28
+ mode = @input.console_mode
29
+ scope.on_dispose { @input.console_mode = mode }
30
+ @input.raw!
31
+ scope.on_dispose do
32
+ @output.print "\r\e[2K"
33
+ @output.flush
34
+ end
35
+ @output.print STATUS
36
+ @output.flush
37
+ deadline = @clock.call + (milliseconds / 1000.0)
38
+ loop do
39
+ remaining = deadline - @clock.call
40
+ if remaining <= 0
41
+ # A burst arriving at the deadline can leave bytes queued after
42
+ # the last processed key. Do not let its Enter submit a later turn.
43
+ if @input.respond_to?(:nread) && @input.nread.positive?
44
+ @input.iflush
45
+ @output.print "\a\r\e[2K[Rubik] Bekleme sırasında gelen tuşlar yok sayıldı.\r\n"
46
+ end
47
+ return :execute
48
+ end
49
+
50
+ byte = @reader.call(remaining)
51
+ next if byte.nil?
52
+ return :cancel if [:eof, "\x03"].include?(byte)
53
+
54
+ if byte == "\e"
55
+ escape = classify_escape
56
+ return escape unless escape == :sequence
57
+ end
58
+
59
+ # Other keys are intentionally rejected, with immediate feedback.
60
+ # Enter never leaks into the next readline as an accidental submit.
61
+ @output.print "\a\r\e[2K#{STATUS} · Tuş yok sayıldı"
62
+ @output.flush
63
+ end
64
+ rescue Interrupt
65
+ :cancel
66
+ ensure
67
+ scope&.dispose
68
+ end
69
+
70
+ private
71
+
72
+ def read_byte(timeout)
73
+ return nil unless @input.wait_readable(timeout)
74
+
75
+ @input.read_nonblock(1)
76
+ rescue IO::WaitReadable
77
+ nil
78
+ rescue EOFError
79
+ :eof
80
+ end
81
+
82
+ # CSI/SS3 arrows and function keys start with Esc too. Resolve this
83
+ # ambiguity even when Esc arrives just before the submission deadline.
84
+ def classify_escape
85
+ following = @reader.call(ESCAPE_TIMEOUT)
86
+ return :edit if following.nil? || following == :eof
87
+ return :cancel if following == "\x03"
88
+ return :sequence unless ["[", "O"].include?(following)
89
+
90
+ deadline = @clock.call + ESCAPE_TIMEOUT
91
+ loop do
92
+ remaining = deadline - @clock.call
93
+ break if remaining <= 0
94
+
95
+ byte = @reader.call(remaining)
96
+ return :cancel if byte == "\x03"
97
+ break if byte.nil? || byte == :eof || byte.match?(/[\x40-\x7e]/)
98
+ end
99
+ :sequence
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+ require "tempfile"
5
+ require "timeout"
6
+ require_relative "discovery"
7
+ require_relative "diagnostic_engine"
8
+ require_relative "../lsp/client"
9
+
10
+ module Rubik
11
+ module Runtime
12
+ # Functional checks own their resources through the caller's probe context.
13
+ class RepairProbe
14
+ def initialize(root_dir:)
15
+ @root_dir = root_dir
16
+ end
17
+
18
+ PROBE_TIMEOUT = 5
19
+ # These commands support help without running tests or formatting files.
20
+ HELP_PROBE_BINARIES = ["ruby", "python", "python3", "pytest", "rspec", "rake", "bundle", "rubocop", "standardrb", "ruff", "black", "rustfmt", "cargo", "go", "gofmt",
21
+ "goimports", "clang-format", "zig", "prettier", "biome", "npm", "pnpm", "yarn", "bun", "mix", "dart", "crystal", "echo"].freeze
22
+
23
+ def verify(target, lang, proposed_cmd, sub_ctx, probe_args: nil)
24
+ return probe_failure(proposed_cmd, 1, "Unknown target component type: #{target}") unless ["lsp", "test_runner", "linter", "command"].include?(target)
25
+ unless Rubik::Runtime::Discovery.binary_available?(proposed_cmd, root_dir: @root_dir)
26
+ return probe_failure(proposed_cmd, 127, "Availability check failed: executable not found in PATH")
27
+ end
28
+
29
+ if target == "lsp"
30
+ client = Rubik::LSP::Client.new(lang, root_dir: @root_dir, server_cmd: proposed_cmd)
31
+ sub_ctx.on_dispose { client.stop }
32
+ begin
33
+ result = Timeout.timeout(PROBE_TIMEOUT) { client.start }
34
+ return probe_failure(proposed_cmd, 1, "Functional probe failed: LSP initialize handshake rejected") unless result && client.alive?
35
+ rescue Timeout::Error
36
+ return probe_failure(proposed_cmd, 124, "LSP initialize handshake timed out")
37
+ end
38
+ return [true, nil]
39
+ end
40
+
41
+ argv = Shellwords.split(proposed_cmd)
42
+ if probe_args.nil?
43
+ unless HELP_PROBE_BINARIES.include?(File.basename(argv.first.to_s))
44
+ return probe_failure(proposed_cmd, 1, "Available but unverified: provide non-mutating probe_args for this executable")
45
+ end
46
+
47
+ probe_args = ["--help"]
48
+ end
49
+ return probe_failure(proposed_cmd, 1, "probe_args must be a non-empty array of strings") unless probe_args.is_a?(Array) && !probe_args.empty? && probe_args.all?(String)
50
+
51
+ argv.concat(probe_args)
52
+ # Array-form spawn never interprets shell operators. File-backed output
53
+ # avoids pipe-reader threads surviving a timeout or a forked descendant.
54
+ output = Tempfile.new("rubik-repair-probe")
55
+ sub_ctx.on_dispose { output.close! }
56
+ options = { chdir: @root_dir, in: File::NULL, out: output, err: output }
57
+ options[:pgroup] = true unless Rubik::Platform.windows?
58
+ pid = Process.spawn(*argv, **options)
59
+ reaped = false
60
+ sub_ctx.on_dispose do
61
+ if Rubik::Platform.windows?
62
+ Rubik::Platform.kill_tree(pid) unless reaped
63
+ else
64
+ begin
65
+ Process.kill("KILL", -pid)
66
+ rescue Errno::ESRCH
67
+ nil
68
+ end
69
+ end
70
+ Process.waitpid(pid) unless reaped
71
+ rescue Errno::ECHILD
72
+ nil
73
+ end
74
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + PROBE_TIMEOUT
75
+ loop do
76
+ waited = Process.waitpid2(pid, Process::WNOHANG)
77
+ if waited
78
+ reaped = true
79
+ status = waited.last
80
+ return [true, nil] if status.success?
81
+
82
+ output.rewind
83
+ return probe_failure(proposed_cmd, status.exitstatus || 1, "Functional probe failed: #{output.read(4096)}")
84
+ end
85
+ return probe_failure(proposed_cmd, 124, "Functional probe timed out") if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
86
+
87
+ sleep 0.01
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def probe_failure(command, code, message)
94
+ [false, Rubik::Runtime::DiagnosticEngine.diagnose(cmd: command, exit_code: code, stderr: message)]
95
+ end
96
+ end
97
+ end
98
+ end
@@ -59,6 +59,25 @@ module Rubik
59
59
  "If genuinely required and safe, the operator can allowlist it once:",
60
60
  " !allow-cmd add #{quoted}"
61
61
  ]
62
+ elsif kind == :classifier_error
63
+ # AST walk crashed (fail-closed): the command was never
64
+ # classified, so no rule matched — blame-free guidance
65
+ # plus the explicit statement that AST evaluation failed.
66
+ [
67
+ "Security blocked: #{message}",
68
+ "The command AST could not be evaluated (classifier error), so the command was not classified.",
69
+ "If genuinely required and safe, the operator can allowlist the exact simple command once:",
70
+ " !allow-cmd add #{quoted}"
71
+ ]
72
+ elsif kind == :parse_ambiguous
73
+ # Tree-sitter reported syntax errors: a partial verdict is
74
+ # not a verdict — the command was blocked unparsed.
75
+ [
76
+ "Security blocked: #{message}",
77
+ "The command AST could not be fully parsed (tree-sitter syntax error), so the command was not classified.",
78
+ "If genuinely required and safe, the operator can allowlist it once:",
79
+ " !allow-cmd add #{quoted}"
80
+ ]
62
81
  elsif cmd && err.respond_to?(:probe_like) && err.probe_like
63
82
  # Probe branch first: `python3 --version` is probe-shaped and
64
83
  # must not be hijacked by the text-munging variant below.
@@ -109,6 +109,13 @@ module Rubik
109
109
  # whose ONLY arguments are these flags is read-only by construction.
110
110
  SAFE_PROBE_FLAGS = ["--version", "-v", "--help", "-h"].freeze
111
111
 
112
+ # Shell metacharacters that make a raw (AST-less) token-prefix allowlist
113
+ # match dishonest: they can join, redirect, substitute, or line-split
114
+ # the string, so "the first tokens match" would not mean "the whole
115
+ # simple command matches". The classifier-crash rescue only accepts a
116
+ # single simple command; anything else fails closed.
117
+ RAW_ALLOWLIST_UNSAFE_PATTERN = /[;&|<>`$(){}\[\]\\\n\r]/
118
+
112
119
  attr_reader :audit_log_path
113
120
 
114
121
  # Live-reconfigurable audit destination (ConfigWiring applies config
@@ -486,16 +493,55 @@ module Rubik
486
493
  )
487
494
  end
488
495
 
496
+ # Ambiguous parse (tree-sitter syntax errors) is a classification
497
+ # failure, not a pass: the walk cannot vouch for the whole
498
+ # expression, so a safe-looking partial verdict must not fall
499
+ # through to the catastrophic-only regex gate. The allowlist stays
500
+ # the valve because a real AST exists — every segment is checked
501
+ # against the operator entries.
502
+ if !allow_unknown && ast.parse_warnings.to_a.any?
503
+ allowlisted = begin
504
+ command_allowlisted?(ast, cmd_str: cmd_str)
505
+ rescue StandardError => e
506
+ warn "[Guard] allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
507
+ false
508
+ end
509
+ if allowlisted
510
+ log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist")
511
+ verify_command_fallback_regex!(cmd_str)
512
+ return true
513
+ end
514
+ log_audit("blocked_command", command: cmd_str, reason: "parse_ambiguous",
515
+ parse_warnings: ast.parse_warnings.to_a.size)
516
+ raise BlockedWriteError.new(
517
+ "Command AST could not be fully parsed (tree-sitter syntax error); execution blocked (fail-closed): #{cmd_str}",
518
+ kind: :parse_ambiguous,
519
+ blocked_command: cmd_str,
520
+ rule_name: "parse_ambiguous",
521
+ probe_like: probe_shape?(cmd_str),
522
+ segment_report: nil
523
+ )
524
+ end
525
+
489
526
  return true if [:readonly, :local_effects, :external_effects].include?(ast.classification) && ast.parse_warnings.empty?
490
527
  rescue BlockedWriteError
491
528
  raise
492
529
  rescue StandardError => e
493
- # Unconditional warn + audit trail: a classifier crash downgrades
494
- # this verification to the narrow fallback regex, silently skipping
495
- # allowlist/probe logic — a security-observable downgrade nobody
496
- # should need RUBIK_DEBUG to notice.
497
- warn "[WARN] AST classification error, falling back to regex gate: #{e.message}"
498
- log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}")
530
+ # Unconditional warn + audit trail: a classifier crash is a
531
+ # security-observable downgrade nobody should need RUBIK_DEBUG to
532
+ # notice. The shell gate fails closed — an exception must never
533
+ # mint execution permission; the operator allowlist is the one
534
+ # sanctioned valve. The synthesis seam (allow_unknown) keeps
535
+ # flowing into its own deny layers (DANGEROUS_SYNTHESIS_PATTERNS
536
+ # + RubyVM walk), which run after this gate.
537
+ if allow_unknown
538
+ warn "[WARN] AST classification error on non-shell input, continuing to synthesis deny layers: #{e.message}"
539
+ log_audit("classifier_error", command: cmd_str, reason: "#{e.class}: #{e.message}",
540
+ decision: "synthesis_layers")
541
+ else
542
+ warn "[WARN] AST classification error, failing closed: #{e.class}: #{e.message}"
543
+ block_on_classifier_failure!(cmd_str, e)
544
+ end
499
545
  end
500
546
 
501
547
  verify_command_fallback_regex!(cmd_str)
@@ -685,6 +731,69 @@ module Rubik
685
731
  report ? report[:segments].size : 0
686
732
  end
687
733
 
734
+ # Classifier-crash gate for the shell path: structured block by
735
+ # default, one sanctioned rescue — an operator allowlist entry
736
+ # covering the exact single-command prefix (the same explicit
737
+ # authorization contract as the unknown-command gate). The
738
+ # catastrophic fallback regex still runs afterwards on the rescue
739
+ # path; on the block path it gets first refusal so a catastrophic
740
+ # command keeps its precise :dangerous_pattern reason.
741
+ def block_on_classifier_failure!(cmd_str, error)
742
+ allowlisted = begin
743
+ command_allowlisted_raw?(cmd_str)
744
+ rescue StandardError => e
745
+ warn "[Guard] raw allowlist evaluation failed, failing closed: #{e.class}: #{e.message}"
746
+ false
747
+ end
748
+ log_audit("classifier_error", command: cmd_str, reason: "#{error.class}: #{error.message}",
749
+ decision: allowlisted ? "operator_allowlist" : "blocked")
750
+
751
+ if allowlisted
752
+ log_audit("allowed_command", command: cmd_str, reason: "operator_allowlist_classifier_error")
753
+ return
754
+ end
755
+
756
+ verify_command_fallback_regex!(cmd_str)
757
+ log_audit("blocked_command", command: cmd_str, reason: "classifier_error")
758
+ raise BlockedWriteError.new(
759
+ "AST evaluation failed, so the command could not be classified; execution blocked (fail-closed): #{cmd_str}",
760
+ kind: :classifier_error,
761
+ blocked_command: cmd_str,
762
+ rule_name: "classifier_error",
763
+ probe_like: probe_shape?(cmd_str),
764
+ segment_report: nil
765
+ )
766
+ end
767
+
768
+ # Token-prefix allowlist check for an input whose AST does not exist
769
+ # (classifier crashed). Only a single simple command qualifies: with
770
+ # shell metacharacters present the string may hold several segments
771
+ # and the gate's "every segment must match" contract cannot be
772
+ # honored — fail closed instead of over-promising the prefix match.
773
+ # Quoting is handled by Shellwords; an unparseable string (unbalanced
774
+ # quote) fails closed.
775
+ def command_allowlisted_raw?(cmd_str)
776
+ return false if @command_allowlist.empty?
777
+ return false if cmd_str.match?(RAW_ALLOWLIST_UNSAFE_PATTERN)
778
+
779
+ tokens = nil
780
+ begin
781
+ tokens = Shellwords.split(cmd_str)
782
+ rescue ArgumentError
783
+ return false
784
+ end
785
+ return false if tokens.empty?
786
+ return false if Rubik::Security::Classifier::Matcher::BUILTIN_DANGEROUS_CMDS.include?(tokens[0].to_s)
787
+
788
+ @command_allowlist.any? do |entry|
789
+ next false if entry.length > tokens.length
790
+
791
+ entry.each_with_index.all? do |token, i|
792
+ i.zero? ? binary_allowlist_match?(tokens[i], token) : tokens[i] == token
793
+ end
794
+ end
795
+ end
796
+
688
797
  def verify_command_fallback_regex!(cmd_str)
689
798
  return true unless DANGEROUS_COMMAND_PATTERNS.any? { |p| cmd_str.match?(p) }
690
799