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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +49 -0
  3. data/GUIDE.md +598 -0
  4. data/LICENSE +21 -0
  5. data/README.md +412 -0
  6. data/bin/verify_prism_parity +112 -0
  7. data/lib/ruby_llm/toolbox/base.rb +112 -0
  8. data/lib/ruby_llm/toolbox/configuration.rb +148 -0
  9. data/lib/ruby_llm/toolbox/data_path.rb +54 -0
  10. data/lib/ruby_llm/toolbox/process_registry.rb +226 -0
  11. data/lib/ruby_llm/toolbox/process_runner.rb +72 -0
  12. data/lib/ruby_llm/toolbox/ruby_outline.rb +213 -0
  13. data/lib/ruby_llm/toolbox/safe_math.rb +182 -0
  14. data/lib/ruby_llm/toolbox/safety/command_guard.rb +42 -0
  15. data/lib/ruby_llm/toolbox/safety/path_jail.rb +55 -0
  16. data/lib/ruby_llm/toolbox/safety/url_guard.rb +111 -0
  17. data/lib/ruby_llm/toolbox/sandbox/base.rb +151 -0
  18. data/lib/ruby_llm/toolbox/sandbox/bubblewrap.rb +70 -0
  19. data/lib/ruby_llm/toolbox/sandbox/docker.rb +69 -0
  20. data/lib/ruby_llm/toolbox/sandbox/sandbox_exec.rb +75 -0
  21. data/lib/ruby_llm/toolbox/search/brave.rb +64 -0
  22. data/lib/ruby_llm/toolbox/search/searxng.rb +64 -0
  23. data/lib/ruby_llm/toolbox/search/tavily.rb +70 -0
  24. data/lib/ruby_llm/toolbox/text_diff.rb +81 -0
  25. data/lib/ruby_llm/toolbox/toml.rb +409 -0
  26. data/lib/ruby_llm/toolbox/tools/apply_patch.rb +92 -0
  27. data/lib/ruby_llm/toolbox/tools/bash_tool.rb +101 -0
  28. data/lib/ruby_llm/toolbox/tools/bundle.rb +71 -0
  29. data/lib/ruby_llm/toolbox/tools/calculator.rb +42 -0
  30. data/lib/ruby_llm/toolbox/tools/create_directory.rb +35 -0
  31. data/lib/ruby_llm/toolbox/tools/csv_read.rb +69 -0
  32. data/lib/ruby_llm/toolbox/tools/csv_write.rb +51 -0
  33. data/lib/ruby_llm/toolbox/tools/date_time.rb +42 -0
  34. data/lib/ruby_llm/toolbox/tools/delete_file.rb +64 -0
  35. data/lib/ruby_llm/toolbox/tools/diff.rb +35 -0
  36. data/lib/ruby_llm/toolbox/tools/download_file.rb +55 -0
  37. data/lib/ruby_llm/toolbox/tools/edit_file.rb +82 -0
  38. data/lib/ruby_llm/toolbox/tools/gem_tool.rb +140 -0
  39. data/lib/ruby_llm/toolbox/tools/git_add.rb +46 -0
  40. data/lib/ruby_llm/toolbox/tools/git_blame.rb +58 -0
  41. data/lib/ruby_llm/toolbox/tools/git_branch.rb +35 -0
  42. data/lib/ruby_llm/toolbox/tools/git_checkout.rb +43 -0
  43. data/lib/ruby_llm/toolbox/tools/git_commit.rb +47 -0
  44. data/lib/ruby_llm/toolbox/tools/git_diff.rb +50 -0
  45. data/lib/ruby_llm/toolbox/tools/git_grep.rb +66 -0
  46. data/lib/ruby_llm/toolbox/tools/git_helpers.rb +68 -0
  47. data/lib/ruby_llm/toolbox/tools/git_log.rb +47 -0
  48. data/lib/ruby_llm/toolbox/tools/git_show.rb +48 -0
  49. data/lib/ruby_llm/toolbox/tools/git_status.rb +27 -0
  50. data/lib/ruby_llm/toolbox/tools/glob.rb +62 -0
  51. data/lib/ruby_llm/toolbox/tools/grep_files.rb +221 -0
  52. data/lib/ruby_llm/toolbox/tools/http_helpers.rb +130 -0
  53. data/lib/ruby_llm/toolbox/tools/http_request.rb +75 -0
  54. data/lib/ruby_llm/toolbox/tools/json_query.rb +69 -0
  55. data/lib/ruby_llm/toolbox/tools/lint.rb +67 -0
  56. data/lib/ruby_llm/toolbox/tools/list_directory.rb +87 -0
  57. data/lib/ruby_llm/toolbox/tools/move_file.rb +54 -0
  58. data/lib/ruby_llm/toolbox/tools/multi_edit.rb +107 -0
  59. data/lib/ruby_llm/toolbox/tools/parse_ruby.rb +111 -0
  60. data/lib/ruby_llm/toolbox/tools/process_kill.rb +41 -0
  61. data/lib/ruby_llm/toolbox/tools/process_list.rb +29 -0
  62. data/lib/ruby_llm/toolbox/tools/process_output.rb +55 -0
  63. data/lib/ruby_llm/toolbox/tools/process_start.rb +109 -0
  64. data/lib/ruby_llm/toolbox/tools/python_tests.rb +77 -0
  65. data/lib/ruby_llm/toolbox/tools/read_file.rb +75 -0
  66. data/lib/ruby_llm/toolbox/tools/replace_in_files.rb +139 -0
  67. data/lib/ruby_llm/toolbox/tools/run_python.rb +38 -0
  68. data/lib/ruby_llm/toolbox/tools/run_ruby.rb +37 -0
  69. data/lib/ruby_llm/toolbox/tools/run_rust.rb +42 -0
  70. data/lib/ruby_llm/toolbox/tools/run_tests.rb +81 -0
  71. data/lib/ruby_llm/toolbox/tools/sandbox_run.rb +40 -0
  72. data/lib/ruby_llm/toolbox/tools/todo_write.rb +57 -0
  73. data/lib/ruby_llm/toolbox/tools/toml_query.rb +70 -0
  74. data/lib/ruby_llm/toolbox/tools/toolchain_helpers.rb +62 -0
  75. data/lib/ruby_llm/toolbox/tools/tree.rb +87 -0
  76. data/lib/ruby_llm/toolbox/tools/web_fetch.rb +77 -0
  77. data/lib/ruby_llm/toolbox/tools/web_search.rb +81 -0
  78. data/lib/ruby_llm/toolbox/tools/write_file.rb +52 -0
  79. data/lib/ruby_llm/toolbox/tools/yaml_query.rb +73 -0
  80. data/lib/ruby_llm/toolbox/truncator.rb +68 -0
  81. data/lib/ruby_llm/toolbox/version.rb +7 -0
  82. data/lib/ruby_llm/toolbox.rb +161 -0
  83. 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