rubik 0.3.3 → 0.3.5
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 +102 -0
- data/lib/rubik/adapter/process.rb +11 -8
- data/lib/rubik/ast/go_extractor.rb +9 -2
- data/lib/rubik/ast/rust_extractor.rb +9 -2
- data/lib/rubik/ast/typescript_extractor.rb +13 -7
- data/lib/rubik/buffer/manager.rb +99 -69
- data/lib/rubik/config/defaults.rb +17 -20
- data/lib/rubik/config.rb +0 -6
- data/lib/rubik/context/git_lister.rb +3 -1
- data/lib/rubik/llm/anthropic.rb +5 -2
- data/lib/rubik/llm/client.rb +102 -72
- data/lib/rubik/llm/openai_compatible.rb +83 -68
- data/lib/rubik/lsp/client.rb +320 -96
- data/lib/rubik/lsp/manager.rb +83 -12
- data/lib/rubik/lsp/server_command.rb +102 -0
- data/lib/rubik/orchestrator/goal_ledger/patch.rb +48 -30
- data/lib/rubik/orchestrator/lsp_feedback.rb +20 -4
- data/lib/rubik/orchestrator/subagent/reuse.rb +2 -2
- data/lib/rubik/orchestrator/subagent/spawn_transaction.rb +60 -31
- 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_context.rb +126 -104
- data/lib/rubik/repl/commands/agent_execution.rb +183 -155
- data/lib/rubik/repl/commands/agent_models.rb +112 -87
- data/lib/rubik/repl/commands/agent_providers.rb +190 -156
- data/lib/rubik/repl/commands/buffer.rb +122 -88
- data/lib/rubik/repl/commands/lsp.rb +212 -179
- data/lib/rubik/repl/commands/mcp.rb +120 -92
- data/lib/rubik/repl/commands/session.rb +90 -59
- data/lib/rubik/repl/commands/workspace.rb +266 -246
- data/lib/rubik/repl/shell.rb +10 -1
- data/lib/rubik/runtime/discovery.rb +12 -6
- 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/storage/vakanuvis/distill_gate.rb +6 -2
- data/lib/rubik/storage/vakanuvis/gate.rb +37 -16
- data/lib/rubik/storage/vakanuvis/harness.rb +36 -21
- data/lib/rubik/storage/vakanuvis/memory_steward.rb +12 -6
- data/lib/rubik/tools/builtins/lifecycle.rb +34 -7
- data/lib/rubik/tools/builtins/lsp.rb +36 -8
- data/lib/rubik/tools/fuzzy_matcher.rb +27 -11
- data/lib/rubik/version.rb +1 -1
- metadata +2 -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)
|
|
@@ -141,114 +149,130 @@ module Rubik
|
|
|
141
149
|
|
|
142
150
|
case sub
|
|
143
151
|
when "list", "status"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
ctx.out.puts
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
else
|
|
156
|
-
extra.each_with_index do |r, idx|
|
|
157
|
-
mode_str = glob_rw || rw_set.include?(r) ? "Read-Write" : "Read-Only"
|
|
158
|
-
ctx.out.puts " • [EXTRA #{idx + 1}] #{r} (#{mode_str})"
|
|
159
|
-
end
|
|
160
|
-
end
|
|
152
|
+
handle_workspace_list(ctx, effective_root)
|
|
153
|
+
when "allow"
|
|
154
|
+
handle_workspace_allow(clean_args[1], ctx, is_ro, scope)
|
|
155
|
+
when "add"
|
|
156
|
+
handle_workspace_add(clean_args[1], ctx, is_rw, scope)
|
|
157
|
+
when "remove", "rm", "delete"
|
|
158
|
+
handle_workspace_remove(clean_args[1], ctx, scope, effective_root)
|
|
159
|
+
else
|
|
160
|
+
ctx.out.puts USAGE_WORKSPACE
|
|
161
|
+
end
|
|
162
|
+
end
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
allowed_ext.each_with_index do |d, idx|
|
|
167
|
-
mode_str = glob_rw || rw_allowed_set.include?(d) ? "Read-Write" : "Read-Only"
|
|
168
|
-
ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
|
|
169
|
-
end
|
|
170
|
-
end
|
|
164
|
+
shell.register_command("workspace", description: "Manage multiple workspace roots (list|add <path> [--rw]|allow <path>|remove <path>)",
|
|
165
|
+
usage: USAGE_WORKSPACE, &workspace_handler)
|
|
166
|
+
shell.register_command("roots", description: "Alias for !workspace", usage: USAGE_WORKSPACE, &workspace_handler)
|
|
167
|
+
end
|
|
171
168
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
169
|
+
private_class_method def self.handle_workspace_list(ctx, effective_root)
|
|
170
|
+
extra = ctx.buffer_manager&.additional_roots || []
|
|
171
|
+
rw_set = ctx.buffer_manager&.rw_roots || Set.new
|
|
172
|
+
allowed_ext = ctx.buffer_manager&.allowed_external_dirs || []
|
|
173
|
+
rw_allowed_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
|
|
174
|
+
glob_rw = ctx.buffer_manager&.allow_external_writes || false
|
|
175
|
+
auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
|
|
176
|
+
|
|
177
|
+
ctx.out.puts "=== Configured Workspace Roots ==="
|
|
178
|
+
ctx.out.puts " • [PRIMARY] #{effective_root} (Read-Write)"
|
|
179
|
+
if extra.empty?
|
|
180
|
+
ctx.out.puts " (No additional workspace roots configured)"
|
|
181
|
+
else
|
|
182
|
+
extra.each_with_index do |r, idx|
|
|
183
|
+
mode_str = glob_rw || rw_set.include?(r) ? "Read-Write" : "Read-Only"
|
|
184
|
+
ctx.out.puts " • [EXTRA #{idx + 1}] #{r} (#{mode_str})"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
185
187
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
ctx.out.puts "=== Allowed External Dirs (Zero-AST) ==="
|
|
189
|
+
if allowed_ext.empty?
|
|
190
|
+
ctx.out.puts " (No allowed external directories configured)"
|
|
191
|
+
else
|
|
192
|
+
allowed_ext.each_with_index do |d, idx|
|
|
193
|
+
mode_str = glob_rw || rw_allowed_set.include?(d) ? "Read-Write" : "Read-Only"
|
|
194
|
+
ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
189
197
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
198
|
+
ctx.out.puts "External Writes Allowed Globally : #{glob_rw}"
|
|
199
|
+
ctx.out.puts "Auto-Index Additional Roots : #{auto_idx}"
|
|
200
|
+
end
|
|
194
201
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if target_path.nil? || target_path.empty?
|
|
201
|
-
ctx.out.puts "Usage: !workspace add <path> [--rw] [--project|--global]"
|
|
202
|
-
else
|
|
203
|
-
begin
|
|
204
|
-
ctx.guard.verify_root_safety!(target_path)
|
|
205
|
-
rescue Rubik::Security::BlockedWriteError => e
|
|
206
|
-
ctx.out.puts "Security Error: #{e.message}"
|
|
207
|
-
next
|
|
208
|
-
end
|
|
202
|
+
private_class_method def self.handle_workspace_allow(target_path, ctx, is_ro, scope)
|
|
203
|
+
if target_path.nil? || target_path.empty?
|
|
204
|
+
ctx.out.puts "Usage: !workspace allow <path> [--rw|--ro] [--project|--global]"
|
|
205
|
+
return
|
|
206
|
+
end
|
|
209
207
|
|
|
210
|
-
|
|
208
|
+
begin
|
|
209
|
+
ctx.guard.verify_root_safety!(target_path)
|
|
210
|
+
rescue Rubik::Security::BlockedWriteError => e
|
|
211
|
+
ctx.out.puts "Security Error: #{e.message}"
|
|
212
|
+
return
|
|
213
|
+
end
|
|
211
214
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
mode_rw = !is_ro
|
|
216
|
+
expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
|
|
217
|
+
ctx.sandbox_runner&.add_allowed_dir(target_path)
|
|
215
218
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
next if Base.report_persist_failure(ctx, res, scope)
|
|
219
|
+
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
220
|
+
current_dirs << target_path unless current_dirs.include?(target_path)
|
|
221
|
+
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
222
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
when "remove", "rm", "delete"
|
|
226
|
-
target_path = clean_args[1]
|
|
227
|
-
if target_path.nil? || target_path.empty?
|
|
228
|
-
ctx.out.puts "Usage: !workspace remove <path> [--project|--global]"
|
|
229
|
-
else
|
|
230
|
-
expanded = ctx.buffer_manager.remove_root(target_path)
|
|
224
|
+
mode_str = mode_rw ? "Read-Write" : "Read-Only"
|
|
225
|
+
ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
|
|
226
|
+
end
|
|
231
227
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
228
|
+
private_class_method def self.handle_workspace_add(target_path, ctx, is_rw, scope)
|
|
229
|
+
if target_path.nil? || target_path.empty?
|
|
230
|
+
ctx.out.puts "Usage: !workspace add <path> [--rw] [--project|--global]"
|
|
231
|
+
return
|
|
232
|
+
end
|
|
235
233
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
begin
|
|
235
|
+
ctx.guard.verify_root_safety!(target_path)
|
|
236
|
+
rescue Rubik::Security::BlockedWriteError => e
|
|
237
|
+
ctx.out.puts "Security Error: #{e.message}"
|
|
238
|
+
return
|
|
239
|
+
end
|
|
241
240
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
expanded = ctx.buffer_manager.add_root(target_path, rw: is_rw)
|
|
242
|
+
|
|
243
|
+
# Sync context index & AST coeffects
|
|
244
|
+
auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
|
|
245
|
+
ctx.sync_workspace_index! if auto_idx
|
|
246
|
+
|
|
247
|
+
# Persist to config
|
|
248
|
+
current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
|
|
249
|
+
current_roots << target_path unless current_roots.include?(target_path)
|
|
250
|
+
res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
|
|
251
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
252
|
+
|
|
253
|
+
mode_str = is_rw ? "Read-Write" : "Read-Only"
|
|
254
|
+
ctx.out.puts "[Rubik::Workspace] Added additional root: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private_class_method def self.handle_workspace_remove(target_path, ctx, scope, effective_root)
|
|
258
|
+
if target_path.nil? || target_path.empty?
|
|
259
|
+
ctx.out.puts "Usage: !workspace remove <path> [--project|--global]"
|
|
260
|
+
return
|
|
247
261
|
end
|
|
248
262
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
expanded = ctx.buffer_manager.remove_root(target_path)
|
|
264
|
+
|
|
265
|
+
# Sync context index & AST coeffects
|
|
266
|
+
auto_idx = ctx.config.fetch("workspace.auto_index_additional", true)
|
|
267
|
+
ctx.sync_workspace_index! if auto_idx
|
|
268
|
+
|
|
269
|
+
# Persist to config
|
|
270
|
+
current_roots = Array(ctx.config.fetch("workspace.additional_roots", [])).dup
|
|
271
|
+
current_roots.reject! { |r| File.expand_path(r, effective_root) == expanded || r == target_path }
|
|
272
|
+
res = ctx.config.set("workspace.additional_roots", current_roots, persist: scope)
|
|
273
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
274
|
+
|
|
275
|
+
ctx.out.puts "[Rubik::Workspace] Removed additional root: #{expanded} [#{Base.scope_label(scope)}]."
|
|
252
276
|
end
|
|
253
277
|
|
|
254
278
|
private_class_method def self.register_allow_command(shell)
|
|
@@ -263,80 +287,16 @@ module Rubik
|
|
|
263
287
|
|
|
264
288
|
case sub
|
|
265
289
|
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
|
|
290
|
+
handle_allow_list(ctx)
|
|
279
291
|
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
|
|
292
|
+
handle_allow_remove(clean_args[1], ctx, scope, effective_root)
|
|
295
293
|
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
|
|
294
|
+
handle_allow_add(clean_args[1], ctx, scope, mode_rw)
|
|
319
295
|
else
|
|
320
296
|
# First argument is target path directly
|
|
321
297
|
target_path = clean_args.first
|
|
322
298
|
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)}]."
|
|
299
|
+
handle_allow_add(target_path, ctx, scope, mode_rw)
|
|
340
300
|
else
|
|
341
301
|
cmd_name = invoked_as == :allow ? "allow" : "allow-dir"
|
|
342
302
|
ctx.out.puts format(USAGE_ALLOW, cmd: cmd_name)
|
|
@@ -359,6 +319,66 @@ module Rubik
|
|
|
359
319
|
usage: format(USAGE_ALLOW, cmd: "allow"), &deprecated_allow_wrapper)
|
|
360
320
|
end
|
|
361
321
|
|
|
322
|
+
private_class_method def self.handle_allow_list(ctx)
|
|
323
|
+
allowed = ctx.buffer_manager&.allowed_external_dirs || []
|
|
324
|
+
rw_set = ctx.buffer_manager&.rw_allowed_dirs || Set.new
|
|
325
|
+
glob_rw = ctx.buffer_manager&.allow_external_writes || false
|
|
326
|
+
|
|
327
|
+
ctx.out.puts "=== Allowed External Directories (Zero-AST / Zero-Scanner) ==="
|
|
328
|
+
if allowed.empty?
|
|
329
|
+
ctx.out.puts " (No allowed external directories configured)"
|
|
330
|
+
else
|
|
331
|
+
allowed.each_with_index do |d, idx|
|
|
332
|
+
mode_str = glob_rw || rw_set.include?(d) ? "Read-Write" : "Read-Only"
|
|
333
|
+
ctx.out.puts " • [ALLOWED #{idx + 1}] #{d} (#{mode_str})"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private_class_method def self.handle_allow_remove(target_path, ctx, scope, effective_root)
|
|
339
|
+
if target_path.nil? || target_path.empty?
|
|
340
|
+
ctx.out.puts "Usage: !allow remove <path> [--project|--global]"
|
|
341
|
+
return
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
expanded = ctx.buffer_manager&.remove_allowed_dir(target_path)
|
|
345
|
+
ctx.sandbox_runner&.remove_allowed_dir(target_path)
|
|
346
|
+
|
|
347
|
+
# Persist to config
|
|
348
|
+
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
349
|
+
current_dirs.reject! { |d| File.expand_path(d, effective_root) == expanded || d == target_path }
|
|
350
|
+
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
351
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
352
|
+
|
|
353
|
+
ctx.out.puts "[Rubik::Allow] Removed allowed external directory: #{expanded} [#{Base.scope_label(scope)}]."
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private_class_method def self.handle_allow_add(target_path, ctx, scope, mode_rw)
|
|
357
|
+
if target_path.nil? || target_path.empty?
|
|
358
|
+
ctx.out.puts "Usage: !allow add <path> [--rw|--ro] [--project|--global]"
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
begin
|
|
363
|
+
ctx.guard.verify_root_safety!(target_path)
|
|
364
|
+
rescue Rubik::Security::BlockedWriteError => e
|
|
365
|
+
ctx.out.puts "Security Error: #{e.message}"
|
|
366
|
+
return
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
expanded = ctx.buffer_manager.add_allowed_dir(target_path, rw: mode_rw)
|
|
370
|
+
ctx.sandbox_runner&.add_allowed_dir(target_path)
|
|
371
|
+
|
|
372
|
+
# Persist to config (Zero-AST / Zero-Scanner: No IndexService or AST warm)
|
|
373
|
+
current_dirs = Array(ctx.config.fetch("workspace.allowed_external_dirs", [])).dup
|
|
374
|
+
current_dirs << target_path unless current_dirs.include?(target_path)
|
|
375
|
+
res = ctx.config.set("workspace.allowed_external_dirs", current_dirs, persist: scope)
|
|
376
|
+
return if Base.report_persist_failure(ctx, res, scope)
|
|
377
|
+
|
|
378
|
+
mode_str = mode_rw ? "Read-Write" : "Read-Only"
|
|
379
|
+
ctx.out.puts "[Rubik::Allow] Added allowed external directory: #{expanded} (Mode: #{mode_str}) [#{Base.scope_label(scope)}]."
|
|
380
|
+
end
|
|
381
|
+
|
|
362
382
|
private_class_method def self.register_config_command(shell)
|
|
363
383
|
shell.register_command("config", description: "Get, set, or view runtime configuration (set|get|list|path)",
|
|
364
384
|
usage: USAGE_CONFIG, audience: :both) do |args, ctx|
|
data/lib/rubik/repl/shell.rb
CHANGED
|
@@ -92,7 +92,16 @@ module Rubik
|
|
|
92
92
|
files.each do |rel_file|
|
|
93
93
|
@context_index.update_file(rel_file)
|
|
94
94
|
full_path = File.join(root_d, rel_file)
|
|
95
|
-
File.exist?(full_path)
|
|
95
|
+
if File.exist?(full_path)
|
|
96
|
+
@ast_graph.index_file(full_path)
|
|
97
|
+
if @lsp_manager&.enabled?
|
|
98
|
+
content = Rubik::Platform.read_text(full_path)
|
|
99
|
+
@lsp_manager.sync_buffer_file(rel_file, content)
|
|
100
|
+
@lsp_manager.did_save_file(rel_file, content)
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
@ast_graph.remove_file(full_path)
|
|
104
|
+
end
|
|
96
105
|
end
|
|
97
106
|
@ast_graph.save_to_cache(@cache_store) if @cache_store
|
|
98
107
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "shellwords"
|
|
4
4
|
require_relative "../platform"
|
|
5
|
+
require_relative "../lsp/server_command"
|
|
5
6
|
|
|
6
7
|
module Rubik
|
|
7
8
|
module Runtime
|
|
@@ -179,22 +180,27 @@ module Rubik
|
|
|
179
180
|
end
|
|
180
181
|
|
|
181
182
|
def binary_available?(cmd, root_dir: nil)
|
|
182
|
-
|
|
183
|
+
argv = Rubik::LSP::ServerCommand.argv(cmd)
|
|
184
|
+
return false if argv.empty?
|
|
183
185
|
|
|
184
|
-
bin =
|
|
185
|
-
if
|
|
186
|
-
full_path = File.expand_path(bin, root_dir)
|
|
186
|
+
bin = argv.first
|
|
187
|
+
if bin.include?("/") || (Rubik::Platform.windows? && bin.include?("\\"))
|
|
188
|
+
full_path = File.expand_path(bin, root_dir || Dir.pwd)
|
|
187
189
|
return true if Rubik::Platform.executable_file?(full_path)
|
|
190
|
+
|
|
191
|
+
return false
|
|
188
192
|
end
|
|
189
193
|
|
|
190
|
-
!Rubik::Platform.which(
|
|
194
|
+
!Rubik::Platform.which(bin).nil?
|
|
195
|
+
rescue ArgumentError
|
|
196
|
+
false
|
|
191
197
|
end
|
|
192
198
|
|
|
193
199
|
def probe_lsp(lang, dir = nil, root_dir: nil, custom_cmd: nil)
|
|
194
200
|
effective_root = root_dir || dir || Dir.pwd
|
|
195
201
|
lang_key = lang.to_s.downcase
|
|
196
202
|
candidates = []
|
|
197
|
-
candidates << custom_cmd
|
|
203
|
+
candidates << custom_cmd if custom_cmd && !Rubik::LSP::ServerCommand.argv(custom_cmd).empty?
|
|
198
204
|
candidates.concat(lsp_candidates(lang_key))
|
|
199
205
|
candidates.uniq!
|
|
200
206
|
|