ruby_llm-toolbox 0.1.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 +7 -0
- data/CHANGELOG.md +49 -0
- data/GUIDE.md +598 -0
- data/LICENSE +21 -0
- data/README.md +412 -0
- data/bin/verify_prism_parity +112 -0
- data/lib/ruby_llm/toolbox/base.rb +112 -0
- data/lib/ruby_llm/toolbox/configuration.rb +148 -0
- data/lib/ruby_llm/toolbox/data_path.rb +54 -0
- data/lib/ruby_llm/toolbox/process_registry.rb +226 -0
- data/lib/ruby_llm/toolbox/process_runner.rb +72 -0
- data/lib/ruby_llm/toolbox/ruby_outline.rb +213 -0
- data/lib/ruby_llm/toolbox/safe_math.rb +182 -0
- data/lib/ruby_llm/toolbox/safety/command_guard.rb +42 -0
- data/lib/ruby_llm/toolbox/safety/path_jail.rb +55 -0
- data/lib/ruby_llm/toolbox/safety/url_guard.rb +111 -0
- data/lib/ruby_llm/toolbox/sandbox/base.rb +151 -0
- data/lib/ruby_llm/toolbox/sandbox/bubblewrap.rb +70 -0
- data/lib/ruby_llm/toolbox/sandbox/docker.rb +69 -0
- data/lib/ruby_llm/toolbox/sandbox/sandbox_exec.rb +75 -0
- data/lib/ruby_llm/toolbox/search/brave.rb +64 -0
- data/lib/ruby_llm/toolbox/search/searxng.rb +64 -0
- data/lib/ruby_llm/toolbox/search/tavily.rb +70 -0
- data/lib/ruby_llm/toolbox/text_diff.rb +81 -0
- data/lib/ruby_llm/toolbox/toml.rb +409 -0
- data/lib/ruby_llm/toolbox/tools/apply_patch.rb +92 -0
- data/lib/ruby_llm/toolbox/tools/bash_tool.rb +101 -0
- data/lib/ruby_llm/toolbox/tools/bundle.rb +71 -0
- data/lib/ruby_llm/toolbox/tools/calculator.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/create_directory.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/csv_read.rb +69 -0
- data/lib/ruby_llm/toolbox/tools/csv_write.rb +51 -0
- data/lib/ruby_llm/toolbox/tools/date_time.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/delete_file.rb +64 -0
- data/lib/ruby_llm/toolbox/tools/diff.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/download_file.rb +55 -0
- data/lib/ruby_llm/toolbox/tools/edit_file.rb +82 -0
- data/lib/ruby_llm/toolbox/tools/gem_tool.rb +140 -0
- data/lib/ruby_llm/toolbox/tools/git_add.rb +46 -0
- data/lib/ruby_llm/toolbox/tools/git_blame.rb +58 -0
- data/lib/ruby_llm/toolbox/tools/git_branch.rb +35 -0
- data/lib/ruby_llm/toolbox/tools/git_checkout.rb +43 -0
- data/lib/ruby_llm/toolbox/tools/git_commit.rb +47 -0
- data/lib/ruby_llm/toolbox/tools/git_diff.rb +50 -0
- data/lib/ruby_llm/toolbox/tools/git_grep.rb +66 -0
- data/lib/ruby_llm/toolbox/tools/git_helpers.rb +68 -0
- data/lib/ruby_llm/toolbox/tools/git_log.rb +47 -0
- data/lib/ruby_llm/toolbox/tools/git_show.rb +48 -0
- data/lib/ruby_llm/toolbox/tools/git_status.rb +27 -0
- data/lib/ruby_llm/toolbox/tools/glob.rb +62 -0
- data/lib/ruby_llm/toolbox/tools/grep_files.rb +221 -0
- data/lib/ruby_llm/toolbox/tools/http_helpers.rb +130 -0
- data/lib/ruby_llm/toolbox/tools/http_request.rb +75 -0
- data/lib/ruby_llm/toolbox/tools/json_query.rb +69 -0
- data/lib/ruby_llm/toolbox/tools/lint.rb +67 -0
- data/lib/ruby_llm/toolbox/tools/list_directory.rb +87 -0
- data/lib/ruby_llm/toolbox/tools/move_file.rb +54 -0
- data/lib/ruby_llm/toolbox/tools/multi_edit.rb +107 -0
- data/lib/ruby_llm/toolbox/tools/parse_ruby.rb +111 -0
- data/lib/ruby_llm/toolbox/tools/process_kill.rb +41 -0
- data/lib/ruby_llm/toolbox/tools/process_list.rb +29 -0
- data/lib/ruby_llm/toolbox/tools/process_output.rb +55 -0
- data/lib/ruby_llm/toolbox/tools/process_start.rb +109 -0
- data/lib/ruby_llm/toolbox/tools/python_tests.rb +77 -0
- data/lib/ruby_llm/toolbox/tools/read_file.rb +75 -0
- data/lib/ruby_llm/toolbox/tools/replace_in_files.rb +139 -0
- data/lib/ruby_llm/toolbox/tools/run_python.rb +38 -0
- data/lib/ruby_llm/toolbox/tools/run_ruby.rb +37 -0
- data/lib/ruby_llm/toolbox/tools/run_rust.rb +42 -0
- data/lib/ruby_llm/toolbox/tools/run_tests.rb +81 -0
- data/lib/ruby_llm/toolbox/tools/sandbox_run.rb +40 -0
- data/lib/ruby_llm/toolbox/tools/todo_write.rb +57 -0
- data/lib/ruby_llm/toolbox/tools/toml_query.rb +70 -0
- data/lib/ruby_llm/toolbox/tools/toolchain_helpers.rb +62 -0
- data/lib/ruby_llm/toolbox/tools/tree.rb +87 -0
- data/lib/ruby_llm/toolbox/tools/web_fetch.rb +77 -0
- data/lib/ruby_llm/toolbox/tools/web_search.rb +81 -0
- data/lib/ruby_llm/toolbox/tools/write_file.rb +52 -0
- data/lib/ruby_llm/toolbox/tools/yaml_query.rb +73 -0
- data/lib/ruby_llm/toolbox/truncator.rb +68 -0
- data/lib/ruby_llm/toolbox/version.rb +7 -0
- data/lib/ruby_llm/toolbox.rb +161 -0
- metadata +194 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Toolbox
|
|
5
|
+
# A small, dependency-free TOML parser. Covers the common surface of TOML
|
|
6
|
+
# 1.0 used by real config files: comments; bare/quoted/dotted keys;
|
|
7
|
+
# [tables] and [[arrays of tables]]; basic/literal strings and their
|
|
8
|
+
# multiline forms; integers (with underscores and 0x/0o/0b), floats
|
|
9
|
+
# (exponents, inf, nan), booleans; offset/local date-times, dates and times
|
|
10
|
+
# (kept as strings); arrays (multiline, nested, heterogeneous); and inline
|
|
11
|
+
# tables. Returns nested Hashes/Arrays with string keys, ready for JSON
|
|
12
|
+
# output or DataPath navigation.
|
|
13
|
+
module Toml
|
|
14
|
+
class ParseError < StandardError; end
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def parse(text)
|
|
19
|
+
Parser.new(text).parse
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Recursive-descent parser over a character cursor.
|
|
23
|
+
class Parser
|
|
24
|
+
DATETIME = /\A(\d{4}-\d{2}-\d{2}([Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(\.\d+)?)/
|
|
25
|
+
|
|
26
|
+
def initialize(source)
|
|
27
|
+
@s = source.to_s
|
|
28
|
+
@i = 0
|
|
29
|
+
@len = @s.length
|
|
30
|
+
@root = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse
|
|
34
|
+
@current = @root
|
|
35
|
+
loop do
|
|
36
|
+
skip_blank
|
|
37
|
+
break if eof?
|
|
38
|
+
|
|
39
|
+
if peek == "["
|
|
40
|
+
parse_table_header
|
|
41
|
+
else
|
|
42
|
+
key = parse_key
|
|
43
|
+
skip_inline_ws
|
|
44
|
+
expect("=")
|
|
45
|
+
value = parse_value
|
|
46
|
+
assign(@current, key, value)
|
|
47
|
+
expect_line_end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
@root
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# --- table headers ------------------------------------------------
|
|
56
|
+
def parse_table_header
|
|
57
|
+
advance # [
|
|
58
|
+
array = false
|
|
59
|
+
if peek == "["
|
|
60
|
+
advance
|
|
61
|
+
array = true
|
|
62
|
+
end
|
|
63
|
+
skip_inline_ws
|
|
64
|
+
keys = parse_key
|
|
65
|
+
skip_inline_ws
|
|
66
|
+
expect("]")
|
|
67
|
+
expect("]") if array
|
|
68
|
+
expect_line_end
|
|
69
|
+
|
|
70
|
+
@current = array ? open_array_table(keys) : open_table(keys)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def open_table(keys)
|
|
74
|
+
node = @root
|
|
75
|
+
keys.each do |k|
|
|
76
|
+
node[k] = {} unless node.key?(k)
|
|
77
|
+
raise ParseError, "cannot redefine #{k.inspect} as a table" unless node[k].is_a?(Hash) || node[k].is_a?(Array)
|
|
78
|
+
node = node[k].is_a?(Array) ? node[k].last : node[k]
|
|
79
|
+
end
|
|
80
|
+
node
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def open_array_table(keys)
|
|
84
|
+
node = @root
|
|
85
|
+
keys[0..-2].each do |k|
|
|
86
|
+
node[k] = {} unless node.key?(k)
|
|
87
|
+
raise ParseError, "cannot redefine #{k.inspect} as a table" unless node[k].is_a?(Hash) || node[k].is_a?(Array)
|
|
88
|
+
node = node[k].is_a?(Array) ? node[k].last : node[k]
|
|
89
|
+
end
|
|
90
|
+
last = keys[-1]
|
|
91
|
+
node[last] ||= []
|
|
92
|
+
raise ParseError, "key #{last.inspect} is not an array of tables" unless node[last].is_a?(Array)
|
|
93
|
+
|
|
94
|
+
fresh = {}
|
|
95
|
+
node[last] << fresh
|
|
96
|
+
fresh
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# --- keys ---------------------------------------------------------
|
|
100
|
+
def parse_key
|
|
101
|
+
parts = []
|
|
102
|
+
loop do
|
|
103
|
+
skip_inline_ws
|
|
104
|
+
parts << parse_key_segment
|
|
105
|
+
skip_inline_ws
|
|
106
|
+
break unless peek == "."
|
|
107
|
+
|
|
108
|
+
advance
|
|
109
|
+
end
|
|
110
|
+
parts
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_key_segment
|
|
114
|
+
case peek
|
|
115
|
+
when '"' then parse_basic_string
|
|
116
|
+
when "'" then parse_literal_string
|
|
117
|
+
else
|
|
118
|
+
start = @i
|
|
119
|
+
advance while !eof? && peek.match?(/[A-Za-z0-9_-]/)
|
|
120
|
+
raise ParseError, "empty key near position #{@i}" if @i == start
|
|
121
|
+
|
|
122
|
+
@s[start...@i]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def assign(table, keys, value)
|
|
127
|
+
node = table
|
|
128
|
+
keys[0..-2].each do |k|
|
|
129
|
+
node[k] = {} unless node.key?(k)
|
|
130
|
+
raise ParseError, "cannot redefine #{k.inspect} as a table" unless node[k].is_a?(Hash)
|
|
131
|
+
node = node[k]
|
|
132
|
+
end
|
|
133
|
+
node[keys[-1]] = value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- values -------------------------------------------------------
|
|
137
|
+
def parse_value
|
|
138
|
+
skip_inline_ws
|
|
139
|
+
case peek
|
|
140
|
+
when '"', "'" then parse_string
|
|
141
|
+
when "[" then parse_array
|
|
142
|
+
when "{" then parse_inline_table
|
|
143
|
+
else parse_atom
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_atom
|
|
148
|
+
rest = @s[@i..]
|
|
149
|
+
if (m = rest.match(DATETIME))
|
|
150
|
+
@i += m[0].length
|
|
151
|
+
return m[0]
|
|
152
|
+
end
|
|
153
|
+
if rest.start_with?("true")
|
|
154
|
+
@i += 4
|
|
155
|
+
return true
|
|
156
|
+
end
|
|
157
|
+
if rest.start_with?("false")
|
|
158
|
+
@i += 5
|
|
159
|
+
return false
|
|
160
|
+
end
|
|
161
|
+
parse_number
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def parse_number
|
|
165
|
+
start = @i
|
|
166
|
+
advance while !eof? && !peek.match?(/[\s,\]}#]/)
|
|
167
|
+
token = @s[start...@i]
|
|
168
|
+
raise ParseError, "expected a value near position #{start}" if token.empty?
|
|
169
|
+
|
|
170
|
+
classify_number(token)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def classify_number(token)
|
|
174
|
+
t = token.gsub("_", "")
|
|
175
|
+
return Float::INFINITY if %w[inf +inf].include?(t)
|
|
176
|
+
return -Float::INFINITY if t == "-inf"
|
|
177
|
+
return Float::NAN if %w[nan +nan -nan].include?(t)
|
|
178
|
+
|
|
179
|
+
if (m = t.match(/\A[+-]?0(x|o|b)(.+)\z/))
|
|
180
|
+
base = { "x" => 16, "o" => 8, "b" => 2 }[m[1]]
|
|
181
|
+
return Integer(m[2], base)
|
|
182
|
+
end
|
|
183
|
+
return Float(t) if t.match?(/[.eE]/) && !t.match?(/\A[+-]?0x/i)
|
|
184
|
+
|
|
185
|
+
Integer(t, 10)
|
|
186
|
+
rescue ArgumentError
|
|
187
|
+
raise ParseError, "invalid number: #{token.inspect}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# --- strings ------------------------------------------------------
|
|
191
|
+
def parse_string
|
|
192
|
+
if @s[@i, 3] == '"""'
|
|
193
|
+
parse_multiline_basic
|
|
194
|
+
elsif @s[@i, 3] == "'''"
|
|
195
|
+
parse_multiline_literal
|
|
196
|
+
elsif peek == '"'
|
|
197
|
+
parse_basic_string
|
|
198
|
+
else
|
|
199
|
+
parse_literal_string
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def parse_basic_string
|
|
204
|
+
advance # opening "
|
|
205
|
+
out = +""
|
|
206
|
+
until eof?
|
|
207
|
+
c = peek
|
|
208
|
+
raise ParseError, "unterminated string" if c == "\n"
|
|
209
|
+
|
|
210
|
+
if c == '"'
|
|
211
|
+
advance
|
|
212
|
+
return out
|
|
213
|
+
elsif c == "\\"
|
|
214
|
+
advance
|
|
215
|
+
out << read_escape
|
|
216
|
+
else
|
|
217
|
+
out << c
|
|
218
|
+
advance
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
raise ParseError, "unterminated string"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def parse_literal_string
|
|
225
|
+
advance # opening '
|
|
226
|
+
start = @i
|
|
227
|
+
advance while !eof? && peek != "'" && peek != "\n"
|
|
228
|
+
raise ParseError, "unterminated literal string" if eof? || peek == "\n"
|
|
229
|
+
|
|
230
|
+
str = @s[start...@i]
|
|
231
|
+
advance # closing '
|
|
232
|
+
str
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def parse_multiline_basic
|
|
236
|
+
@i += 3
|
|
237
|
+
@i += 1 if peek == "\n"
|
|
238
|
+
out = +""
|
|
239
|
+
until eof?
|
|
240
|
+
if @s[@i, 3] == '"""'
|
|
241
|
+
@i += 3
|
|
242
|
+
return out
|
|
243
|
+
elsif peek == "\\" && @s[@i + 1..].match?(/\A[ \t]*\r?\n/)
|
|
244
|
+
@i += 1
|
|
245
|
+
@i += 1 while !eof? && peek.match?(/[\s]/)
|
|
246
|
+
elsif peek == "\\"
|
|
247
|
+
advance
|
|
248
|
+
out << read_escape
|
|
249
|
+
else
|
|
250
|
+
out << peek
|
|
251
|
+
advance
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
raise ParseError, "unterminated multiline string"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def parse_multiline_literal
|
|
258
|
+
@i += 3
|
|
259
|
+
@i += 1 if peek == "\n"
|
|
260
|
+
start = @i
|
|
261
|
+
until eof?
|
|
262
|
+
if @s[@i, 3] == "'''"
|
|
263
|
+
str = @s[start...@i]
|
|
264
|
+
@i += 3
|
|
265
|
+
return str
|
|
266
|
+
end
|
|
267
|
+
advance
|
|
268
|
+
end
|
|
269
|
+
raise ParseError, "unterminated multiline literal string"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def read_escape
|
|
273
|
+
c = peek
|
|
274
|
+
advance
|
|
275
|
+
case c
|
|
276
|
+
when "n" then "\n"
|
|
277
|
+
when "t" then "\t"
|
|
278
|
+
when "r" then "\r"
|
|
279
|
+
when "b" then "\b"
|
|
280
|
+
when "f" then "\f"
|
|
281
|
+
when '"' then '"'
|
|
282
|
+
when "\\" then "\\"
|
|
283
|
+
when "u" then read_unicode(4)
|
|
284
|
+
when "U" then read_unicode(8)
|
|
285
|
+
else raise ParseError, "invalid escape: \\#{c}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def read_unicode(width)
|
|
290
|
+
hex = @s[@i, width]
|
|
291
|
+
raise ParseError, "invalid unicode escape" unless hex && hex.match?(/\A[0-9A-Fa-f]{#{width}}\z/)
|
|
292
|
+
|
|
293
|
+
@i += width
|
|
294
|
+
[hex.to_i(16)].pack("U")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# --- arrays & inline tables --------------------------------------
|
|
298
|
+
def parse_array
|
|
299
|
+
advance # [
|
|
300
|
+
arr = []
|
|
301
|
+
loop do
|
|
302
|
+
skip_ws_comments
|
|
303
|
+
break if eof?
|
|
304
|
+
|
|
305
|
+
if peek == "]"
|
|
306
|
+
advance
|
|
307
|
+
return arr
|
|
308
|
+
end
|
|
309
|
+
arr << parse_value
|
|
310
|
+
skip_ws_comments
|
|
311
|
+
if peek == ","
|
|
312
|
+
advance
|
|
313
|
+
elsif peek == "]"
|
|
314
|
+
advance
|
|
315
|
+
return arr
|
|
316
|
+
else
|
|
317
|
+
raise ParseError, "expected ',' or ']' in array near position #{@i}"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
raise ParseError, "unterminated array"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def parse_inline_table
|
|
324
|
+
advance # {
|
|
325
|
+
table = {}
|
|
326
|
+
skip_ws_comments
|
|
327
|
+
if peek == "}"
|
|
328
|
+
advance
|
|
329
|
+
return table
|
|
330
|
+
end
|
|
331
|
+
loop do
|
|
332
|
+
skip_ws_comments
|
|
333
|
+
key = parse_key
|
|
334
|
+
skip_inline_ws
|
|
335
|
+
expect("=")
|
|
336
|
+
assign(table, key, parse_value)
|
|
337
|
+
skip_ws_comments
|
|
338
|
+
if peek == ","
|
|
339
|
+
advance
|
|
340
|
+
elsif peek == "}"
|
|
341
|
+
advance
|
|
342
|
+
return table
|
|
343
|
+
else
|
|
344
|
+
raise ParseError, "expected ',' or '}' in inline table near position #{@i}"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# --- cursor helpers ----------------------------------------------
|
|
350
|
+
def peek
|
|
351
|
+
@s[@i]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def advance
|
|
355
|
+
@i += 1
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def eof?
|
|
359
|
+
@i >= @len
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def expect(char)
|
|
363
|
+
raise ParseError, "expected #{char.inspect} near position #{@i}, got #{peek.inspect}" unless peek == char
|
|
364
|
+
|
|
365
|
+
advance
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def skip_inline_ws
|
|
369
|
+
advance while !eof? && (peek == " " || peek == "\t")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Between top-level statements: whitespace, newlines and comment lines.
|
|
373
|
+
def skip_blank
|
|
374
|
+
loop do
|
|
375
|
+
if !eof? && peek.match?(/[ \t\r\n]/)
|
|
376
|
+
advance
|
|
377
|
+
elsif peek == "#"
|
|
378
|
+
advance while !eof? && peek != "\n"
|
|
379
|
+
else
|
|
380
|
+
break
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Inside arrays/inline tables: whitespace, newlines and comments.
|
|
386
|
+
def skip_ws_comments
|
|
387
|
+
skip_blank
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# After a key/value or header: optional comment, then newline or EOF.
|
|
391
|
+
def expect_line_end
|
|
392
|
+
skip_inline_ws
|
|
393
|
+
if peek == "#"
|
|
394
|
+
advance while !eof? && peek != "\n"
|
|
395
|
+
end
|
|
396
|
+
return if eof?
|
|
397
|
+
if peek == "\r"
|
|
398
|
+
advance
|
|
399
|
+
end
|
|
400
|
+
if peek == "\n"
|
|
401
|
+
advance
|
|
402
|
+
elsif !eof?
|
|
403
|
+
raise ParseError, "expected end of line near position #{@i}, got #{peek.inspect}"
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/git_helpers"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Applies a unified diff to files in fs_root using `git apply`.
|
|
10
|
+
# Complements edit_file when a model wants to emit a whole multi-hunk
|
|
11
|
+
# patch. The patch is validated with `git apply --check` before anything
|
|
12
|
+
# is written. Patch paths are also checked against PathJail so a patch
|
|
13
|
+
# cannot escape fs_root even when fs_root is a subdirectory of a git repo.
|
|
14
|
+
# Works with or without a git repository.
|
|
15
|
+
class ApplyPatch < Base
|
|
16
|
+
include GitHelpers
|
|
17
|
+
exec_tool!
|
|
18
|
+
|
|
19
|
+
description "Apply a unified diff to files in fs_root (as produced by `git diff` / `diff -u`). " \
|
|
20
|
+
"Validates the patch first and reports the affected files; nothing is written if it " \
|
|
21
|
+
"would not apply cleanly. Set check: true for a dry run only."
|
|
22
|
+
|
|
23
|
+
param :patch, type: "string",
|
|
24
|
+
desc: "The unified diff text to apply.",
|
|
25
|
+
required: true
|
|
26
|
+
param :check, type: "boolean",
|
|
27
|
+
desc: "Validate only (dry run); do not write changes. Default false.",
|
|
28
|
+
required: false
|
|
29
|
+
|
|
30
|
+
def execute(patch:, check: false)
|
|
31
|
+
diff = patch.to_s
|
|
32
|
+
diff += "\n" unless diff.end_with?("\n")
|
|
33
|
+
return error("patch is empty", code: :empty_patch) if diff.strip.empty?
|
|
34
|
+
|
|
35
|
+
validate_patch_paths!(diff)
|
|
36
|
+
|
|
37
|
+
verify = run_git("apply", "--check", stdin: diff)
|
|
38
|
+
unless succeeded?(verify)
|
|
39
|
+
return error("patch does not apply cleanly: #{message(verify)}", code: :patch_failed)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
files = changed_files(diff)
|
|
43
|
+
return "Patch applies cleanly (dry run). Affected files: #{files.join(', ')}" if check
|
|
44
|
+
|
|
45
|
+
result = run_git("apply", stdin: diff)
|
|
46
|
+
return error("apply failed: #{message(result)}", code: :patch_failed) unless succeeded?(result)
|
|
47
|
+
|
|
48
|
+
"Applied patch to #{files.size} file#{files.size == 1 ? '' : 's'}: #{files.join(', ')}"
|
|
49
|
+
rescue Safety::PathJail::Jailbreak => e
|
|
50
|
+
error("patch path escapes fs_root: #{e.message}", code: :patch_failed)
|
|
51
|
+
rescue Errno::ENOENT
|
|
52
|
+
error("git is not available on the host", code: :unavailable)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def succeeded?((_out, _err, status))
|
|
58
|
+
status != :timeout && status.exitstatus&.zero?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_patch_paths!(diff)
|
|
62
|
+
jail = path_jail
|
|
63
|
+
diff.each_line do |line|
|
|
64
|
+
if line.start_with?("+++ ")
|
|
65
|
+
path = line[4..].chomp.sub(/\Ab\//, "")
|
|
66
|
+
next if path.empty? || path == "/dev/null"
|
|
67
|
+
jail.resolve(path)
|
|
68
|
+
elsif line.start_with?("--- ")
|
|
69
|
+
path = line[4..].chomp.sub(/\Aa\//, "")
|
|
70
|
+
next if path.empty? || path == "/dev/null"
|
|
71
|
+
jail.resolve(path)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def message((out, err, status))
|
|
77
|
+
return "timed out after #{config.command_timeout}s" if status == :timeout
|
|
78
|
+
|
|
79
|
+
(err.to_s.empty? ? out.to_s : err.to_s).strip
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def changed_files(diff)
|
|
83
|
+
out, = run_git("apply", "--numstat", stdin: diff)
|
|
84
|
+
out.to_s.each_line.filter_map do |line|
|
|
85
|
+
parts = line.strip.split("\t")
|
|
86
|
+
parts.last if parts.size >= 3
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/process_runner"
|
|
5
|
+
require "ruby_llm/toolbox/safety/command_guard"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Toolbox
|
|
9
|
+
module Tools
|
|
10
|
+
# EXEC reference tool. Runs ONE allowlisted executable with arguments.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately NOT a shell: there are no pipes, redirects, globs, quoting,
|
|
13
|
+
# or variable expansion. The program goes in `command`; each argument is a
|
|
14
|
+
# separate element of `args` and is passed verbatim as argv. This is the
|
|
15
|
+
# safe primitive that the OS-command-injection class of bug cannot touch,
|
|
16
|
+
# because no shell ever parses the input.
|
|
17
|
+
#
|
|
18
|
+
# Gated: refuses to run unless config.enable_exec_tools is true AND the
|
|
19
|
+
# executable is on config.allowed_commands.
|
|
20
|
+
class BashTool < Base
|
|
21
|
+
exec_tool!
|
|
22
|
+
|
|
23
|
+
description "Run a single ALLOWLISTED executable with arguments. " \
|
|
24
|
+
"No shell is involved: no pipes, redirects, globs, or variable expansion. " \
|
|
25
|
+
"Put the program name in `command` and each argument as its own element of `args`."
|
|
26
|
+
|
|
27
|
+
param :command, type: "string",
|
|
28
|
+
desc: "Executable name (must be on the allowlist). No path, no shell characters.",
|
|
29
|
+
required: true
|
|
30
|
+
param :args, type: "array",
|
|
31
|
+
desc: "Arguments passed verbatim to the program, one per element. Optional.",
|
|
32
|
+
required: false
|
|
33
|
+
param :unsafe, type: "boolean",
|
|
34
|
+
desc: "Request bypassing the command allowlist (still no shell; argv only). " \
|
|
35
|
+
"Only takes effect if an operator enabled allow_unsafe; otherwise refused. " \
|
|
36
|
+
"Default false.",
|
|
37
|
+
required: false
|
|
38
|
+
|
|
39
|
+
def execute(command:, args: nil, unsafe: false)
|
|
40
|
+
exe = resolve_command(command, unsafe)
|
|
41
|
+
argv = sanitize_args(args)
|
|
42
|
+
|
|
43
|
+
out, err, status = ProcessRunner.capture(
|
|
44
|
+
[exe, *argv],
|
|
45
|
+
env: clean_env,
|
|
46
|
+
timeout: config.command_timeout,
|
|
47
|
+
unsetenv_others: true
|
|
48
|
+
)
|
|
49
|
+
truncate(format_result(exe, argv, out, err, status))
|
|
50
|
+
rescue Safety::CommandGuard::Blocked => e
|
|
51
|
+
error(e.message, code: :command_denied)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# With an operator-permitted unsafe override, skip the allowlist (the
|
|
57
|
+
# program may even be an absolute path) but keep the invariants that make
|
|
58
|
+
# this not-a-shell: no NUL bytes, argv form only, never a shell string.
|
|
59
|
+
def resolve_command(command, unsafe)
|
|
60
|
+
return Safety::CommandGuard.new(config.allowed_commands).check!(command) unless permit_unsafe!(unsafe, command)
|
|
61
|
+
|
|
62
|
+
cmd = command.to_s
|
|
63
|
+
raise Safety::CommandGuard::Blocked, "command is empty" if cmd.strip.empty?
|
|
64
|
+
raise Safety::CommandGuard::Blocked, "command contains a NUL byte" if cmd.include?("\u0000")
|
|
65
|
+
|
|
66
|
+
cmd
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sanitize_args(args)
|
|
70
|
+
Array(args).map do |arg|
|
|
71
|
+
str = arg.to_s
|
|
72
|
+
if str.include?("\u0000")
|
|
73
|
+
raise Safety::CommandGuard::Blocked, "argument contains a NUL byte"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
str
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clean_env
|
|
81
|
+
config.env_passthrough.each_with_object({}) do |key, env|
|
|
82
|
+
value = ENV[key]
|
|
83
|
+
env[key] = value unless value.nil?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_result(exe, argv, out, err, status)
|
|
88
|
+
body = +"argv: #{([exe] + argv).inspect}\n"
|
|
89
|
+
body << if status == :timeout
|
|
90
|
+
"result: timed out after #{config.command_timeout}s (killed)\n"
|
|
91
|
+
else
|
|
92
|
+
"exit: #{status.exitstatus}\n"
|
|
93
|
+
end
|
|
94
|
+
body << "\n--- stdout ---\n#{out}" unless out.empty?
|
|
95
|
+
body << "\n--- stderr ---\n#{err}" unless err.empty?
|
|
96
|
+
body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm/toolbox/base"
|
|
4
|
+
require "ruby_llm/toolbox/tools/toolchain_helpers"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Toolbox
|
|
8
|
+
module Tools
|
|
9
|
+
# EXEC. Runs Bundler operations in the project at fs_root. The mutating
|
|
10
|
+
# sibling of the read-only `gem` tool. install/update/add reach the network
|
|
11
|
+
# and change the project's gems, so this is exec-gated.
|
|
12
|
+
class Bundle < Base
|
|
13
|
+
include ToolchainHelpers
|
|
14
|
+
exec_tool!
|
|
15
|
+
|
|
16
|
+
description "Run Bundler in the project at fs_root. Actions: install, update, outdated, check, " \
|
|
17
|
+
"lock, add. For 'add' provide a gem name; for 'update' a gem name is optional " \
|
|
18
|
+
"(omit to update all). Requires a Gemfile."
|
|
19
|
+
|
|
20
|
+
ACTIONS = %w[install update outdated check lock add].freeze
|
|
21
|
+
NAME_RE = /\A[A-Za-z0-9_.-]+\z/
|
|
22
|
+
|
|
23
|
+
param :action, type: "string",
|
|
24
|
+
desc: "One of: install, update, outdated, check, lock, add.",
|
|
25
|
+
required: true
|
|
26
|
+
param :gem, type: "string",
|
|
27
|
+
desc: "Gem name — required for 'add', optional for 'update'.",
|
|
28
|
+
required: false
|
|
29
|
+
|
|
30
|
+
def execute(action:, gem: nil)
|
|
31
|
+
act = action.to_s.strip.downcase
|
|
32
|
+
return error("unknown action: #{act} (use #{ACTIONS.join(', ')})", code: :bad_action) unless ACTIONS.include?(act)
|
|
33
|
+
return error("a Gemfile is required (none at fs_root)", code: :no_gemfile) unless gemfile?
|
|
34
|
+
|
|
35
|
+
args = build_args(act, gem)
|
|
36
|
+
return args if args.is_a?(Hash) # validation error
|
|
37
|
+
|
|
38
|
+
out, err, status = run_in_project(args, use_bundle: false)
|
|
39
|
+
toolchain_output(out, err, status,
|
|
40
|
+
pass_label: "bundle #{act}: ok",
|
|
41
|
+
fail_label: "bundle #{act}: failed")
|
|
42
|
+
rescue CommandMissing
|
|
43
|
+
error("bundler is not available (gem install bundler)", code: :unavailable)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def build_args(act, gem_name)
|
|
49
|
+
case act
|
|
50
|
+
when "add"
|
|
51
|
+
name = gem_name.to_s.strip
|
|
52
|
+
return error("'add' requires a valid gem name", code: :bad_name) unless name.match?(NAME_RE)
|
|
53
|
+
|
|
54
|
+
["bundle", "add", name]
|
|
55
|
+
when "update"
|
|
56
|
+
name = gem_name.to_s.strip
|
|
57
|
+
if name.empty?
|
|
58
|
+
["bundle", "update"]
|
|
59
|
+
else
|
|
60
|
+
return error("invalid gem name: #{name.inspect}", code: :bad_name) unless name.match?(NAME_RE)
|
|
61
|
+
|
|
62
|
+
["bundle", "update", name]
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
["bundle", act]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|