rubik 0.3.3 → 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 +48 -0
- data/lib/rubik/buffer/manager.rb +21 -3
- data/lib/rubik/config/defaults.rb +16 -20
- 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/reuse.rb +2 -2
- data/lib/rubik/platform/process.rb +6 -6
- data/lib/rubik/platform/sanitize.rb +37 -16
- 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/workspace.rb +156 -152
- 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/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|
|
|
@@ -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
|
|
@@ -153,8 +153,13 @@ module Rubik
|
|
|
153
153
|
pref = Array.new(f_lines.size + 1, 0)
|
|
154
154
|
f_lines.each_with_index { |l, idx| pref[idx + 1] = pref[idx] + (target_set.include?(l) ? 1 : 0) }
|
|
155
155
|
|
|
156
|
-
(
|
|
157
|
-
|
|
156
|
+
# PERF: while loop with direct index increments avoids Enumerator/Range allocations per call (~17-20% faster, ~12% fewer allocations).
|
|
157
|
+
i = 0
|
|
158
|
+
f_lines_size = f_lines.size
|
|
159
|
+
has_diff_lcs = defined?(Diff::LCS) && Diff::LCS.respond_to?(:LCS)
|
|
160
|
+
while i < f_lines_size
|
|
161
|
+
rem = f_lines_size - i
|
|
162
|
+
window_len = [k, rem].min
|
|
158
163
|
total = [k, window_len].max
|
|
159
164
|
|
|
160
165
|
overlap_count = pref[i + window_len] - pref[i]
|
|
@@ -168,27 +173,38 @@ module Rubik
|
|
|
168
173
|
end
|
|
169
174
|
else
|
|
170
175
|
# PERF: Upper-bound check — if maximum possible overlap cannot beat best_score, skip LCS
|
|
171
|
-
|
|
176
|
+
if (overlap_count.to_f / total) <= best_score
|
|
177
|
+
i += 1
|
|
178
|
+
next
|
|
179
|
+
end
|
|
172
180
|
|
|
173
181
|
# PERF: Array#[start, len] slice avoids Range object allocation inside hot loop
|
|
174
182
|
window = f_lines[i, window_len]
|
|
175
183
|
|
|
176
184
|
matches =
|
|
177
|
-
if
|
|
185
|
+
if has_diff_lcs
|
|
178
186
|
Diff::LCS.LCS(t_lines, window).size
|
|
179
187
|
else
|
|
180
188
|
# Fallback line matching
|
|
181
|
-
|
|
189
|
+
m = 0
|
|
190
|
+
idx = 0
|
|
191
|
+
limit = [t_lines.size, window.size].min
|
|
192
|
+
while idx < limit
|
|
193
|
+
m += 1 if t_lines[idx] == window[idx]
|
|
194
|
+
idx += 1
|
|
195
|
+
end
|
|
196
|
+
m
|
|
182
197
|
end
|
|
183
198
|
score = total.positive? ? (matches.to_f / total) : 0.0
|
|
184
199
|
end
|
|
185
200
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
201
|
+
if score > best_score
|
|
202
|
+
best_score = score
|
|
203
|
+
best_range = (i + 1)..(i + window_len)
|
|
204
|
+
best_i = i
|
|
205
|
+
best_window_len = window_len
|
|
206
|
+
end
|
|
207
|
+
i += 1
|
|
192
208
|
end
|
|
193
209
|
|
|
194
210
|
return nil if best_score < 0.25 || best_i.nil?
|
data/lib/rubik/version.rb
CHANGED