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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +130 -0
- data/README.md +30 -17
- data/lib/rubik/ast/extraction_result.rb +37 -0
- data/lib/rubik/ast/extractor.rb +35 -9
- data/lib/rubik/ast/graph.rb +27 -1
- data/lib/rubik/ast/treesitter/c_walker.rb +136 -28
- data/lib/rubik/ast/treesitter_extractor.rb +45 -17
- data/lib/rubik/buffer/apply_contract.rb +78 -0
- data/lib/rubik/buffer/manager.rb +61 -5
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/concurrency/event_loop.rb +56 -44
- data/lib/rubik/concurrency/scheduler.rb +7 -24
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/context/git_lister.rb +6 -1
- data/lib/rubik/context/scanner.rb +15 -3
- data/lib/rubik/mcp/transport/stdio.rb +14 -11
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/platform/process.rb +74 -2
- data/lib/rubik/plugin/manager.rb +148 -71
- data/lib/rubik/plugin/package_inventory.rb +84 -0
- data/lib/rubik/plugin/requires_dag.rb +40 -0
- data/lib/rubik/plugin/storage.rb +100 -14
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/buffer.rb +28 -18
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/runtime/repair_probe.rb +98 -0
- data/lib/rubik/security/blocked_guidance.rb +19 -0
- data/lib/rubik/security/guard.rb +115 -6
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/lifecycle.rb +129 -136
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- 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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
ctx.
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
data/lib/rubik/security/guard.rb
CHANGED
|
@@ -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
|
|
494
|
-
#
|
|
495
|
-
#
|
|
496
|
-
#
|
|
497
|
-
|
|
498
|
-
|
|
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
|
|