rubik 0.3.2 → 0.3.4
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/CHANGELOG.md +77 -0
- data/lib/rubik/buffer/manager.rb +21 -3
- data/lib/rubik/config/defaults.rb +19 -26
- data/lib/rubik/config/store.rb +1 -1
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/subagent/budget.rb +17 -10
- data/lib/rubik/orchestrator/subagent/instance.rb +14 -2
- data/lib/rubik/orchestrator/subagent/journal.rb +56 -13
- data/lib/rubik/orchestrator/subagent/reuse.rb +3 -3
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +8 -5
- data/lib/rubik/orchestrator/subagent/supervisor.rb +7 -1
- data/lib/rubik/platform/process.rb +7 -7
- data/lib/rubik/platform/sanitize.rb +41 -20
- data/lib/rubik/platform.rb +2 -2
- data/lib/rubik/prompt/doctrine.rb +4 -0
- data/lib/rubik/repl/commands/agent_execution.rb +100 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/subagents.rb +12 -11
- data/lib/rubik/repl/commands/workspace.rb +156 -152
- data/lib/rubik/repl/interrupt_handler.rb +10 -10
- data/lib/rubik/runtime/overlay_storage.rb +59 -2
- data/lib/rubik/sandbox/driver/docker.rb +2 -2
- data/lib/rubik/session/replayer.rb +1 -2
- data/lib/rubik/storage/store.rb +3 -2
- data/lib/rubik/tools/builtins/subagent.rb +7 -2
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +1 -1
|
@@ -34,101 +34,109 @@ module Rubik
|
|
|
34
34
|
scope, clean_args = Base.parse_scope(args)
|
|
35
35
|
sub = clean_args.first&.downcase || "list"
|
|
36
36
|
|
|
37
|
-
handle_add = lambda do |tokens_to_add|
|
|
38
|
-
entry_tokens = tokens_to_add.reject { |a| a == "--force" }
|
|
39
|
-
entry = entry_tokens.join(" ").strip
|
|
40
|
-
if entry.empty?
|
|
41
|
-
ctx.out.puts 'Usage: !allow-cmd add "<command prefix>" [--project|--global] [--force]'
|
|
42
|
-
return
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
tokens = entry.split(/\s+/)
|
|
46
|
-
# Fail closed (G3 triage, Priority-1 site): a classifier crash
|
|
47
|
-
# must not silently skip the broad-token RCE check — treat the
|
|
48
|
-
# token as broad and let the operator pass a narrow prefix.
|
|
49
|
-
broad = begin
|
|
50
|
-
base = Rubik::Security::Classifier.classify(tokens.first)
|
|
51
|
-
base.classification == :dangerous && tokens.size == 1
|
|
52
|
-
rescue StandardError => e
|
|
53
|
-
Rubik::Log.warn(:security, "classifier failed for '#{tokens.first}' (#{e.class}: #{e.message}); treating as broad")
|
|
54
|
-
true
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Fail-closed against unbounded execution: a broad, single-token
|
|
58
|
-
# dangerous entry (e.g. "node", "python", "bash") grants
|
|
59
|
-
# command execution for EVERY invocation of that binary, so
|
|
60
|
-
# `node` → `node -e 'malicious()'` RCE. Such broad tokens are
|
|
61
|
-
# blocked with or without --force — the operator should use a
|
|
62
|
-
# narrow prefix (e.g. "node .gitnexus/run.cjs").
|
|
63
|
-
if broad
|
|
64
|
-
ctx.out.puts "Blocked: '#{entry}' is a broad, single-token dangerous command. Even with --force it grants " \
|
|
65
|
-
"unbounded command execution (e.g. `#{tokens.first} -e 'malicious(...)'` RCE). " \
|
|
66
|
-
"Use a narrow prefix instead (e.g. \"#{tokens.first} <script>\")."
|
|
67
|
-
return
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# The catastrophic destructive-pattern fallback always runs:
|
|
71
|
-
# a dangerous multi-token entry may never add `rm -rf /`-style
|
|
72
|
-
# chains to the allowlist, with or without --force.
|
|
73
|
-
catastrophic = false
|
|
74
|
-
begin
|
|
75
|
-
ctx.guard.verify_command_fallback_regex!(entry)
|
|
76
|
-
rescue Rubik::Security::BlockedWriteError => e
|
|
77
|
-
ctx.out.puts "Blocked: '#{entry}' matches a catastrophic destructive pattern and cannot be allowlisted: #{e.message}"
|
|
78
|
-
catastrophic = true
|
|
79
|
-
end
|
|
80
|
-
return if catastrophic
|
|
81
|
-
|
|
82
|
-
current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
|
|
83
|
-
if current.include?(entry)
|
|
84
|
-
ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is already allowlisted."
|
|
85
|
-
return
|
|
86
|
-
end
|
|
87
|
-
current << entry
|
|
88
|
-
res = ctx.config.set("security.command_allowlist", current, persist: scope)
|
|
89
|
-
ctx.guard&.log_audit("allowlist_add", command: entry, scope: scope)
|
|
90
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
91
|
-
|
|
92
|
-
ctx.out.puts "[Rubik::AllowCmd] Added command prefix: #{entry} [#{Base.scope_label(scope)}]."
|
|
93
|
-
end
|
|
94
|
-
|
|
95
37
|
case sub
|
|
96
38
|
when "list", "status"
|
|
97
|
-
|
|
98
|
-
ctx.out.puts "=== Allowed Command Prefixes (operator allowlist) ==="
|
|
99
|
-
if entries.empty?
|
|
100
|
-
ctx.out.puts " (No command prefixes allowlisted — unknown/dangerous commands stay blocked)"
|
|
101
|
-
else
|
|
102
|
-
entries.each_with_index { |e, idx| ctx.out.puts " • [#{idx + 1}] #{e}" }
|
|
103
|
-
end
|
|
39
|
+
handle_allow_cmd_list(ctx)
|
|
104
40
|
when "add"
|
|
105
|
-
|
|
41
|
+
handle_allow_cmd_add(ctx, clean_args[1..], scope)
|
|
106
42
|
when "remove", "rm", "delete"
|
|
107
|
-
|
|
108
|
-
if entry.empty?
|
|
109
|
-
ctx.out.puts "Usage: !allow-cmd remove \"<command prefix>\" [--project|--global]"
|
|
110
|
-
next
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
|
|
114
|
-
unless current.include?(entry)
|
|
115
|
-
ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is not in the allowlist."
|
|
116
|
-
next
|
|
117
|
-
end
|
|
118
|
-
current.delete(entry)
|
|
119
|
-
res = ctx.config.set("security.command_allowlist", current, persist: scope)
|
|
120
|
-
ctx.guard&.log_audit("allowlist_remove", command: entry, scope: scope)
|
|
121
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
122
|
-
|
|
123
|
-
ctx.out.puts "[Rubik::AllowCmd] Removed command prefix: #{entry} [#{Base.scope_label(scope)}]."
|
|
43
|
+
handle_allow_cmd_remove(ctx, clean_args, scope)
|
|
124
44
|
when "help", "-h", "--help"
|
|
125
45
|
ctx.out.puts USAGE_ALLOW_CMD
|
|
126
46
|
else
|
|
127
|
-
|
|
47
|
+
handle_allow_cmd_add(ctx, clean_args, scope)
|
|
128
48
|
end
|
|
129
49
|
end
|
|
130
50
|
end
|
|
131
51
|
|
|
52
|
+
private_class_method def self.handle_allow_cmd_list(ctx)
|
|
53
|
+
entries = Array(ctx.config.fetch("security.command_allowlist", []))
|
|
54
|
+
ctx.out.puts "=== Allowed Command Prefixes (operator allowlist) ==="
|
|
55
|
+
if entries.empty?
|
|
56
|
+
ctx.out.puts " (No command prefixes allowlisted — unknown/dangerous commands stay blocked)"
|
|
57
|
+
else
|
|
58
|
+
entries.each_with_index { |e, idx| ctx.out.puts " • [#{idx + 1}] #{e}" }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method def self.handle_allow_cmd_add(ctx, tokens_to_add, scope)
|
|
63
|
+
entry_tokens = tokens_to_add.reject { |a| a == "--force" }
|
|
64
|
+
entry = entry_tokens.join(" ").strip
|
|
65
|
+
if entry.empty?
|
|
66
|
+
ctx.out.puts 'Usage: !allow-cmd add "<command prefix>" [--project|--global] [--force]'
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
tokens = entry.split(/\s+/)
|
|
71
|
+
# Fail closed (G3 triage, Priority-1 site): a classifier crash
|
|
72
|
+
# must not silently skip the broad-token RCE check — treat the
|
|
73
|
+
# token as broad and let the operator pass a narrow prefix.
|
|
74
|
+
broad = begin
|
|
75
|
+
base = Rubik::Security::Classifier.classify(tokens.first)
|
|
76
|
+
base.classification == :dangerous && tokens.size == 1
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
Rubik::Log.warn(:security, "classifier failed for '#{tokens.first}' (#{e.class}: #{e.message}); treating as broad")
|
|
79
|
+
true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Fail-closed against unbounded execution: a broad, single-token
|
|
83
|
+
# dangerous entry (e.g. "node", "python", "bash") grants
|
|
84
|
+
# command execution for EVERY invocation of that binary, so
|
|
85
|
+
# `node` → `node -e 'malicious()'` RCE. Such broad tokens are
|
|
86
|
+
# blocked with or without --force — the operator should use a
|
|
87
|
+
# narrow prefix (e.g. "node .gitnexus/run.cjs").
|
|
88
|
+
if broad
|
|
89
|
+
ctx.out.puts "Blocked: '#{entry}' is a broad, single-token dangerous command. Even with --force it grants " \
|
|
90
|
+
"unbounded command execution (e.g. `#{tokens.first} -e 'malicious(...)'` RCE). " \
|
|
91
|
+
"Use a narrow prefix instead (e.g. \"#{tokens.first} <script>\")."
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The catastrophic destructive-pattern fallback always runs:
|
|
96
|
+
# a dangerous multi-token entry may never add `rm -rf /`-style
|
|
97
|
+
# chains to the allowlist, with or without --force.
|
|
98
|
+
catastrophic = false
|
|
99
|
+
begin
|
|
100
|
+
ctx.guard.verify_command_fallback_regex!(entry)
|
|
101
|
+
rescue Rubik::Security::BlockedWriteError => e
|
|
102
|
+
ctx.out.puts "Blocked: '#{entry}' matches a catastrophic destructive pattern and cannot be allowlisted: #{e.message}"
|
|
103
|
+
catastrophic = true
|
|
104
|
+
end
|
|
105
|
+
return if catastrophic
|
|
106
|
+
|
|
107
|
+
current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
|
|
108
|
+
if current.include?(entry)
|
|
109
|
+
ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is already allowlisted."
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
current << entry
|
|
113
|
+
res = ctx.config.set("security.command_allowlist", current, persist: scope)
|
|
114
|
+
ctx.guard&.log_audit("allowlist_add", command: entry, scope: scope)
|
|
115
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
116
|
+
|
|
117
|
+
ctx.out.puts "[Rubik::AllowCmd] Added command prefix: #{entry} [#{Base.scope_label(scope)}]."
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private_class_method def self.handle_allow_cmd_remove(ctx, clean_args, scope)
|
|
121
|
+
entry = clean_args[1..].join(" ").strip
|
|
122
|
+
if entry.empty?
|
|
123
|
+
ctx.out.puts "Usage: !allow-cmd remove \"<command prefix>\" [--project|--global]"
|
|
124
|
+
return
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
current = Array(ctx.config.fetch("security.command_allowlist", [])).dup
|
|
128
|
+
unless current.include?(entry)
|
|
129
|
+
ctx.out.puts "[Rubik::AllowCmd] '#{entry}' is not in the allowlist."
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
current.delete(entry)
|
|
133
|
+
res = ctx.config.set("security.command_allowlist", current, persist: scope)
|
|
134
|
+
ctx.guard&.log_audit("allowlist_remove", command: entry, scope: scope)
|
|
135
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
136
|
+
|
|
137
|
+
ctx.out.puts "[Rubik::AllowCmd] Removed command prefix: #{entry} [#{Base.scope_label(scope)}]."
|
|
138
|
+
end
|
|
139
|
+
|
|
132
140
|
private_class_method def self.register_workspace_command(shell)
|
|
133
141
|
workspace_handler = lambda do |args, ctx|
|
|
134
142
|
scope, clean_args = Base.parse_scope(args)
|
|
@@ -263,80 +271,16 @@ module Rubik
|
|
|
263
271
|
|
|
264
272
|
case sub
|
|
265
273
|
when "list", "status"
|
|
266
|
-
|
|
267
|
-
rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
|
|
268
|
-
glob_rw = ctx.buffer_manager&.allow_external_writes || false
|
|
269
|
-
|
|
270
|
-
ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
|
|
271
|
-
if allowed.empty?
|
|
272
|
-
ctx.out.puts " (No allowed external directories configured)"
|
|
273
|
-
else
|
|
274
|
-
allowed.each_with_index do |d, idx|
|
|
275
|
-
mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
|
|
276
|
-
ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
|
|
277
|
-
end
|
|
278
|
-
end
|
|
274
|
+
handle_allow_list(ctx)
|
|
279
275
|
when "remove", "rm", "delete"
|
|
280
|
-
|
|
281
|
-
if target_path.nil? || target_path.empty?
|
|
282
|
-
ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
|
|
283
|
-
else
|
|
284
|
-
expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
|
|
285
|
-
ctx.sandbox_runner&.remove_allowed_dir(target_path)
|
|
286
|
-
|
|
287
|
-
# Persist to config
|
|
288
|
-
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
289
|
-
current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
|
|
290
|
-
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
291
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
292
|
-
|
|
293
|
-
ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
|
|
294
|
-
end
|
|
276
|
+
handle_allow_remove(clean_args[1], ctx, scope, effective_root)
|
|
295
277
|
when "add"
|
|
296
|
-
|
|
297
|
-
if target_path.nil? || target_path.empty?
|
|
298
|
-
ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
|
|
299
|
-
else
|
|
300
|
-
begin
|
|
301
|
-
ctx.guard.verify_root_safety!(target_path)
|
|
302
|
-
rescue Rubik::Security::BlockedWriteError => e
|
|
303
|
-
ctx.out.puts "Security Error: #{e.message}"
|
|
304
|
-
next
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
|
|
308
|
-
ctx.sandbox_runner&.add_allowed_dir(target_path)
|
|
309
|
-
|
|
310
|
-
# Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
|
|
311
|
-
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
312
|
-
current_dirs << target_path unless current_dirs.include?(target_path)
|
|
313
|
-
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
314
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
315
|
-
|
|
316
|
-
mode_str = mode_rw ? "Read-Write" : "Read-Only"
|
|
317
|
-
ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
|
|
318
|
-
end
|
|
278
|
+
handle_allow_add(clean_args[1], ctx, scope, mode_rw)
|
|
319
279
|
else
|
|
320
280
|
# First argument is target path directly
|
|
321
281
|
target_path = clean_args.first
|
|
322
282
|
if target_path && !target_path.empty?
|
|
323
|
-
|
|
324
|
-
ctx.guard.verify_root_safety!(target_path)
|
|
325
|
-
rescue Rubik::Security::BlockedWriteError => e
|
|
326
|
-
ctx.out.puts "Security Error: #{e.message}"
|
|
327
|
-
next
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
|
|
331
|
-
ctx.sandbox_runner&.add_allowed_dir(target_path)
|
|
332
|
-
|
|
333
|
-
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
334
|
-
current_dirs << target_path unless current_dirs.include?(target_path)
|
|
335
|
-
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
336
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
337
|
-
|
|
338
|
-
mode_str = mode_rw ? "Read-Write" : "Read-Only"
|
|
339
|
-
ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
|
|
283
|
+
handle_allow_add(target_path, ctx, scope, mode_rw)
|
|
340
284
|
else
|
|
341
285
|
cmd_name = invoked_as == :allow ? "allow" : "allow-dir"
|
|
342
286
|
ctx.out.puts format(USAGE_ALLOW, cmd: cmd_name)
|
|
@@ -359,6 +303,66 @@ module Rubik
|
|
|
359
303
|
usage: format(USAGE_ALLOW, cmd: "allow"), &deprecated_allow_wrapper)
|
|
360
304
|
end
|
|
361
305
|
|
|
306
|
+
private_class_method def self.handle_allow_list(ctx)
|
|
307
|
+
allowed = ctx.buffer_manager&.allowed_external_dirs || []
|
|
308
|
+
rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
|
|
309
|
+
glob_rw = ctx.buffer_manager&.allow_external_writes || false
|
|
310
|
+
|
|
311
|
+
ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
|
|
312
|
+
if allowed.empty?
|
|
313
|
+
ctx.out.puts " (No allowed external directories configured)"
|
|
314
|
+
else
|
|
315
|
+
allowed.each_with_index do |d, idx|
|
|
316
|
+
mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
|
|
317
|
+
ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
private_class_method def self.handle_allow_remove(target_path, ctx, scope, effective_root)
|
|
323
|
+
if target_path.nil? || target_path.empty?
|
|
324
|
+
ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
|
|
325
|
+
return
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
|
|
329
|
+
ctx.sandbox_runner&.remove_allowed_dir(target_path)
|
|
330
|
+
|
|
331
|
+
# Persist to config
|
|
332
|
+
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
333
|
+
current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
|
|
334
|
+
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
335
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
336
|
+
|
|
337
|
+
ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private_class_method def self.handle_allow_add(target_path, ctx, scope, mode_rw)
|
|
341
|
+
if target_path.nil? || target_path.empty?
|
|
342
|
+
ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
|
|
343
|
+
return
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
begin
|
|
347
|
+
ctx.guard.verify_root_safety!(target_path)
|
|
348
|
+
rescue Rubik::Security::BlockedWriteError => e
|
|
349
|
+
ctx.out.puts "Security Error: #{e.message}"
|
|
350
|
+
return
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
|
|
354
|
+
ctx.sandbox_runner&.add_allowed_dir(target_path)
|
|
355
|
+
|
|
356
|
+
# Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
|
|
357
|
+
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
358
|
+
current_dirs << target_path unless current_dirs.include?(target_path)
|
|
359
|
+
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
360
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
361
|
+
|
|
362
|
+
mode_str = mode_rw ? "Read-Write" : "Read-Only"
|
|
363
|
+
ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
|
|
364
|
+
end
|
|
365
|
+
|
|
362
366
|
private_class_method def self.register_config_command(shell)
|
|
363
367
|
shell.register_command("config", description: "Get, set, or view runtime configuration (set|get|list|path)",
|
|
364
368
|
usage: USAGE_CONFIG, audience: :both) do |args, ctx|
|
|
@@ -89,14 +89,14 @@ module Rubik
|
|
|
89
89
|
if get_std_handle
|
|
90
90
|
new_in = begin
|
|
91
91
|
get_std_handle.call(Reline::Windows::STD_INPUT_HANDLE)
|
|
92
|
-
rescue
|
|
92
|
+
rescue StandardError
|
|
93
93
|
nil
|
|
94
94
|
end
|
|
95
95
|
io.instance_variable_set(:@hConsoleInputHandle, new_in) if new_in && new_in != 0
|
|
96
96
|
|
|
97
97
|
new_out = begin
|
|
98
98
|
get_std_handle.call(Reline::Windows::STD_OUTPUT_HANDLE)
|
|
99
|
-
rescue
|
|
99
|
+
rescue StandardError
|
|
100
100
|
nil
|
|
101
101
|
end
|
|
102
102
|
io.instance_variable_set(:@hConsoleHandle, new_out) if new_out && new_out != 0
|
|
@@ -105,14 +105,14 @@ module Rubik
|
|
|
105
105
|
flush_func = io.instance_variable_get(:@FlushConsoleInputBuffer) ||
|
|
106
106
|
begin
|
|
107
107
|
Reline::Windows::Win32API.new("kernel32", "FlushConsoleInputBuffer", ["L"], "I")
|
|
108
|
-
rescue
|
|
108
|
+
rescue StandardError
|
|
109
109
|
nil
|
|
110
110
|
end
|
|
111
111
|
if flush_func
|
|
112
112
|
io.instance_variable_set(:@FlushConsoleInputBuffer, flush_func)
|
|
113
113
|
begin
|
|
114
114
|
flush_func.call(new_in)
|
|
115
|
-
rescue
|
|
115
|
+
rescue StandardError
|
|
116
116
|
nil
|
|
117
117
|
end
|
|
118
118
|
end
|
|
@@ -143,7 +143,7 @@ if defined?(Reline::Windows)
|
|
|
143
143
|
if @hConsoleInputHandle.nil? || @hConsoleInputHandle.zero? || @hConsoleInputHandle == -1
|
|
144
144
|
@hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
|
|
145
145
|
@GetStdHandle.call(STD_INPUT_HANDLE)
|
|
146
|
-
rescue
|
|
146
|
+
rescue StandardError
|
|
147
147
|
nil
|
|
148
148
|
end
|
|
149
149
|
end
|
|
@@ -156,18 +156,18 @@ if defined?(Reline::Windows)
|
|
|
156
156
|
if [0xFFFFFFFF, 4_294_967_295, -1].include?(wait_res)
|
|
157
157
|
@hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
|
|
158
158
|
@GetStdHandle.call(STD_INPUT_HANDLE)
|
|
159
|
-
rescue
|
|
159
|
+
rescue StandardError
|
|
160
160
|
nil
|
|
161
161
|
end
|
|
162
162
|
@hConsoleHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
|
|
163
163
|
@GetStdHandle.call(STD_OUTPUT_HANDLE)
|
|
164
|
-
rescue
|
|
164
|
+
rescue StandardError
|
|
165
165
|
nil
|
|
166
166
|
end
|
|
167
167
|
end
|
|
168
168
|
@legacy_console = begin
|
|
169
169
|
getconsolemode.nobits?(ENABLE_VIRTUAL_TERMINAL_PROCESSING)
|
|
170
|
-
rescue
|
|
170
|
+
rescue StandardError
|
|
171
171
|
false
|
|
172
172
|
end
|
|
173
173
|
next
|
|
@@ -175,7 +175,7 @@ if defined?(Reline::Windows)
|
|
|
175
175
|
if @GetNumberOfConsoleInputEvents.call(@hConsoleInputHandle, num_of_events).zero? || num_of_events.unpack1("L").zero?
|
|
176
176
|
@hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
|
|
177
177
|
@GetStdHandle.call(STD_INPUT_HANDLE)
|
|
178
|
-
rescue
|
|
178
|
+
rescue StandardError
|
|
179
179
|
nil
|
|
180
180
|
end
|
|
181
181
|
next
|
|
@@ -185,7 +185,7 @@ if defined?(Reline::Windows)
|
|
|
185
185
|
if @ReadConsoleInputW.call(@hConsoleInputHandle, input_records, 80, read_event).zero?
|
|
186
186
|
@hConsoleInputHandle = begin # rubocop:disable Naming/VariableName -- Win32 FFI SDK adlandırması; yalnızca Windows konsol yolu
|
|
187
187
|
@GetStdHandle.call(STD_INPUT_HANDLE)
|
|
188
|
-
rescue
|
|
188
|
+
rescue StandardError
|
|
189
189
|
nil
|
|
190
190
|
end
|
|
191
191
|
else
|
|
@@ -48,7 +48,10 @@ module Rubik
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def manifest_path(dir)
|
|
51
|
-
|
|
51
|
+
ensure_valid_overlay_dir!(dir)
|
|
52
|
+
path = File.join(dir, ".manifest.json")
|
|
53
|
+
ensure_contained_path!(path, dir)
|
|
54
|
+
path
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
def load_manifest(dir)
|
|
@@ -56,7 +59,7 @@ module Rubik
|
|
|
56
59
|
return {} unless File.exist?(path)
|
|
57
60
|
|
|
58
61
|
JSON.parse(File.read(path))
|
|
59
|
-
rescue JSON::ParserError
|
|
62
|
+
rescue JSON::ParserError, ArgumentError
|
|
60
63
|
{}
|
|
61
64
|
end
|
|
62
65
|
|
|
@@ -73,6 +76,7 @@ module Rubik
|
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
def record_hash(dir, filename, scope)
|
|
79
|
+
ensure_valid_overlay_dir!(dir)
|
|
76
80
|
if manifest_state(dir) == :corrupt
|
|
77
81
|
# Bozuk manifesti sessizce "heal" etmek (yalnızca yeni kaydı yazmak) diğer
|
|
78
82
|
# kayıtlı overlay'leri tahliye eder; bunun yerine kaydı reddet.
|
|
@@ -80,6 +84,7 @@ module Rubik
|
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
path = File.join(dir, filename)
|
|
87
|
+
ensure_contained_path!(path, dir)
|
|
83
88
|
hash = Digest::SHA256.hexdigest(normalize_content(File.binread(path)))
|
|
84
89
|
manifest = load_manifest(dir)
|
|
85
90
|
manifest[filename] = {
|
|
@@ -93,6 +98,11 @@ module Rubik
|
|
|
93
98
|
def verify_integrity(file_path, dir, bootstrap_eligible: false)
|
|
94
99
|
return :trusted if trust_mode?
|
|
95
100
|
|
|
101
|
+
unless safe_path_under?(file_path, dir)
|
|
102
|
+
warn "[SECURITY] Path traversal attempt detected for overlay '#{file_path}'. Skipping."
|
|
103
|
+
return :rejected
|
|
104
|
+
end
|
|
105
|
+
|
|
96
106
|
basename = File.basename(file_path)
|
|
97
107
|
manifest = load_manifest(dir)
|
|
98
108
|
current_content = File.exist?(file_path) ? File.binread(file_path) : ""
|
|
@@ -140,6 +150,8 @@ module Rubik
|
|
|
140
150
|
|
|
141
151
|
filename = "#{clean_name.gsub(':', '_')}.rb"
|
|
142
152
|
file_path = File.join(target_dir, filename)
|
|
153
|
+
ensure_contained_path!(file_path, target_dir)
|
|
154
|
+
|
|
143
155
|
header = <<~RUBY
|
|
144
156
|
# frozen_string_literal: true
|
|
145
157
|
#{COMMAND_SENTINEL} !#{clean_name}
|
|
@@ -164,6 +176,7 @@ module Rubik
|
|
|
164
176
|
target_dir = scope.to_sym == :global ? global_commands_dir : project_commands_dir
|
|
165
177
|
filename = "#{clean_name.gsub(':', '_')}.rb"
|
|
166
178
|
file_path = File.join(target_dir, filename)
|
|
179
|
+
ensure_contained_path!(file_path, target_dir)
|
|
167
180
|
|
|
168
181
|
raise "cannot delete '#{filename}': .manifest.json in #{target_dir} is corrupt; delete it and re-record overlays" if manifest_state(target_dir) == :corrupt
|
|
169
182
|
|
|
@@ -190,8 +203,10 @@ module Rubik
|
|
|
190
203
|
|
|
191
204
|
file_path = cmd_meta[:path]
|
|
192
205
|
cmd_name = cmd_meta[:name]
|
|
206
|
+
target_dir = cmd_meta[:scope].to_sym == :global ? global_commands_dir : project_commands_dir
|
|
193
207
|
|
|
194
208
|
begin
|
|
209
|
+
ensure_contained_path!(file_path, target_dir)
|
|
195
210
|
code = File.read(file_path)
|
|
196
211
|
eval_command(shell, cmd_name, code, cmd_meta[:scope])
|
|
197
212
|
loaded << cmd_name
|
|
@@ -249,6 +264,8 @@ module Rubik
|
|
|
249
264
|
|
|
250
265
|
filename = "#{clean_name}.rb"
|
|
251
266
|
file_path = File.join(target_dir, filename)
|
|
267
|
+
ensure_contained_path!(file_path, target_dir)
|
|
268
|
+
|
|
252
269
|
schema_json = params_schema ? JSON.dump(params_schema) : "{}"
|
|
253
270
|
header = <<~RUBY
|
|
254
271
|
# frozen_string_literal: true
|
|
@@ -278,6 +295,7 @@ module Rubik
|
|
|
278
295
|
target_dir = scope.to_sym == :global ? global_tools_dir : project_tools_dir
|
|
279
296
|
filename = "#{clean_name}.rb"
|
|
280
297
|
file_path = File.join(target_dir, filename)
|
|
298
|
+
ensure_contained_path!(file_path, target_dir)
|
|
281
299
|
|
|
282
300
|
raise "cannot delete '#{filename}': .manifest.json in #{target_dir} is corrupt; delete it and re-record overlays" if manifest_state(target_dir) == :corrupt
|
|
283
301
|
|
|
@@ -301,6 +319,10 @@ module Rubik
|
|
|
301
319
|
list_tools.each do |t_meta|
|
|
302
320
|
next if t_meta[:integrity] == :rejected
|
|
303
321
|
|
|
322
|
+
file_path = t_meta[:path]
|
|
323
|
+
target_dir = t_meta[:scope].to_sym == :global ? global_tools_dir : project_tools_dir
|
|
324
|
+
ensure_contained_path!(file_path, target_dir)
|
|
325
|
+
|
|
304
326
|
code = File.read(t_meta[:path])
|
|
305
327
|
tool_inst = eval_tool(t_meta[:name], code, t_meta[:scope])
|
|
306
328
|
if tool_inst
|
|
@@ -357,6 +379,41 @@ module Rubik
|
|
|
357
379
|
|
|
358
380
|
private
|
|
359
381
|
|
|
382
|
+
def ensure_valid_overlay_dir!(dir)
|
|
383
|
+
allowed = [
|
|
384
|
+
[project_commands_dir, @root_dir],
|
|
385
|
+
[global_commands_dir, @home_dir],
|
|
386
|
+
[project_tools_dir, @root_dir],
|
|
387
|
+
[global_tools_dir, @home_dir]
|
|
388
|
+
]
|
|
389
|
+
valid = allowed.any? do |allowed_dir, trusted_root|
|
|
390
|
+
Platform.path_equal?(dir, allowed_dir) && Platform.resolved_path_under?(dir, trusted_root)
|
|
391
|
+
end
|
|
392
|
+
return if valid
|
|
393
|
+
|
|
394
|
+
raise ArgumentError, "Invalid overlay directory: #{dir}"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def ensure_contained_path!(path, parent_dir)
|
|
398
|
+
raise ArgumentError, "Path traversal attempt detected: #{path}" unless safe_path_under?(path, parent_dir)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def safe_path_under?(path, parent_dir)
|
|
402
|
+
return false if path.nil? || parent_dir.nil?
|
|
403
|
+
|
|
404
|
+
ensure_valid_overlay_dir!(parent_dir)
|
|
405
|
+
|
|
406
|
+
# Lexical containment check first
|
|
407
|
+
return false unless Platform.path_under?(path, parent_dir)
|
|
408
|
+
|
|
409
|
+
# If path exists or parent exists, also check resolved path containment
|
|
410
|
+
return Platform.resolved_path_under?(path, parent_dir) if File.exist?(path) || File.exist?(parent_dir)
|
|
411
|
+
|
|
412
|
+
true
|
|
413
|
+
rescue StandardError
|
|
414
|
+
false
|
|
415
|
+
end
|
|
416
|
+
|
|
360
417
|
# Bootstrap kabul taramasında kullanılacak guard: kwarg'la geçilen örnek
|
|
361
418
|
# (üretimde service catalog'un tek operatör-policy guard'ı) ya da lazy
|
|
362
419
|
# default. Lazy çözüm bilinçli: overlay_storage.rb standalone require
|
|
@@ -14,7 +14,7 @@ module Rubik
|
|
|
14
14
|
DEFAULT_IMAGE = "rubik-sandbox:latest"
|
|
15
15
|
|
|
16
16
|
def available?
|
|
17
|
-
!Rubik::Platform.which("docker").nil? && system("docker ps
|
|
17
|
+
!Rubik::Platform.which("docker").nil? && system("docker", "ps", out: File::NULL, err: File::NULL)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def run(cmd, timeout: 15, chdir: Dir.pwd, allow_network: false, memory_limit_mb: 512, cpus: "1.0")
|
|
@@ -48,7 +48,7 @@ module Rubik
|
|
|
48
48
|
error_message: "Docker sandbox error"
|
|
49
49
|
)
|
|
50
50
|
ensure
|
|
51
|
-
system("docker rm -f
|
|
51
|
+
system("docker", "rm", "-f", container_name, out: File::NULL, err: File::NULL)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
{
|
|
@@ -18,9 +18,8 @@ module Rubik
|
|
|
18
18
|
def replay_file(session_file_path)
|
|
19
19
|
return false unless File.exist?(session_file_path)
|
|
20
20
|
|
|
21
|
-
lines = File.readlines(session_file_path)
|
|
22
21
|
@shell&.instance_variable_set(:@replaying, true)
|
|
23
|
-
|
|
22
|
+
File.foreach(session_file_path) do |line|
|
|
24
23
|
next if line.strip.empty?
|
|
25
24
|
|
|
26
25
|
event = begin
|
data/lib/rubik/storage/store.rb
CHANGED
|
@@ -499,10 +499,11 @@ module Rubik
|
|
|
499
499
|
# (elle düzenleme: "42", ["x"]) tüm tipi zehirlemesin.
|
|
500
500
|
skipped = 0
|
|
501
501
|
records = File.foreach(path, encoding: "UTF-8").filter_map do |line|
|
|
502
|
-
|
|
502
|
+
line.strip!
|
|
503
|
+
next if line.empty?
|
|
503
504
|
|
|
504
505
|
parsed = begin
|
|
505
|
-
JSON.parse(line.scrub)
|
|
506
|
+
JSON.parse(line.valid_encoding? ? line : line.scrub)
|
|
506
507
|
rescue JSON::ParserError
|
|
507
508
|
nil
|
|
508
509
|
end
|
|
@@ -251,10 +251,15 @@ module Rubik
|
|
|
251
251
|
return ToolOutput.error("Missing required parameter 'conversation_id' for action 'extend_budget'.") if id.empty?
|
|
252
252
|
|
|
253
253
|
steps = Rubik::Orchestrator::Subagent::Budget.normalize_grant(args["steps"] || args[:steps])
|
|
254
|
+
inst = sup.get(id)
|
|
254
255
|
if sup.extend_budget(id, steps)
|
|
255
|
-
|
|
256
|
+
ceiling = inst.respond_to?(:max_total_steps) ? "/#{inst.max_total_steps}" : ""
|
|
257
|
+
"Step budget for subagent '#{id}' successfully extended by #{steps} steps (total budget: #{inst.max_steps}#{ceiling}, " \
|
|
258
|
+
"state: #{inst.state.to_s.upcase})."
|
|
259
|
+
elsif inst.respond_to?(:max_total_steps) && inst.max_steps >= inst.max_total_steps
|
|
260
|
+
ToolOutput.error("Subagent '#{id}' reached its lifetime step limit (#{inst.max_total_steps}); no more steps can be granted.")
|
|
256
261
|
else
|
|
257
|
-
ToolOutput.error("Subagent '#{id}' not found or cannot extend budget (current state: #{
|
|
262
|
+
ToolOutput.error("Subagent '#{id}' not found or cannot extend budget (current state: #{inst&.state || 'not found'}).")
|
|
258
263
|
end
|
|
259
264
|
when "resume"
|
|
260
265
|
id = resolve_id(args)
|