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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ruby_llm/toolbox/base"
5
+ require "ruby_llm/toolbox/safety/path_jail"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # EXEC. Moves or renames a file or directory within fs_root. BOTH the
11
+ # source and destination are confined to the jail, so nothing can be moved
12
+ # in from or out to outside the root. Refuses to clobber an existing
13
+ # destination unless overwrite is set.
14
+ class MoveFile < Base
15
+ exec_tool!
16
+
17
+ description "Move or rename a file or directory within fs_root. Both source and destination " \
18
+ "must be inside the jail. Missing parent directories of the destination are " \
19
+ "created. Will not overwrite an existing destination unless overwrite is true."
20
+
21
+ param :source, type: "string",
22
+ desc: "Existing path to move, relative to fs_root.",
23
+ required: true
24
+ param :destination, type: "string",
25
+ desc: "Target path, relative to fs_root.",
26
+ required: true
27
+ param :overwrite, type: "boolean",
28
+ desc: "Replace the destination if it already exists. Default false.",
29
+ required: false
30
+
31
+ def execute(source:, destination:, overwrite: false)
32
+ jail = Safety::PathJail.new(config.fs_root)
33
+ from = jail.resolve(source)
34
+ to = jail.resolve(destination)
35
+
36
+ return error("source does not exist: #{source}", code: :not_found) unless File.exist?(from)
37
+ if File.directory?(to) && !File.directory?(from)
38
+ return error("destination is a directory: #{destination}", code: :is_a_directory)
39
+ end
40
+ if File.exist?(to) && !overwrite
41
+ return error("destination already exists: #{destination} (set overwrite to replace)",
42
+ code: :exists)
43
+ end
44
+
45
+ FileUtils.mkdir_p(File.dirname(to))
46
+ FileUtils.mv(from, to, force: overwrite)
47
+ "Moved #{source} -> #{destination}"
48
+ rescue Safety::PathJail::Jailbreak => e
49
+ error(e.message, code: :path_denied)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/safety/path_jail"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # EXEC. Applies several string replacements to one file in a single,
10
+ # atomic operation. Edits are applied in order (a later edit sees the
11
+ # result of earlier ones), each following edit_file's rules: old_string
12
+ # must match exactly once unless replace_all is set. If any edit can't be
13
+ # applied, nothing is written and the failing edit is reported — so the
14
+ # file is never left half-edited.
15
+ class MultiEdit < Base
16
+ class BadEdit < StandardError; end
17
+
18
+ exec_tool!
19
+
20
+ description "Apply multiple find/replace edits to a single file atomically. Provide edits as an " \
21
+ "array of objects with old_string, new_string, and optional replace_all. Edits run " \
22
+ "in order; if any fails to apply cleanly, no changes are written."
23
+
24
+ MAX_BYTES = 10 * 1024 * 1024
25
+
26
+ param :path, type: "string",
27
+ desc: "File to edit, relative to fs_root.",
28
+ required: true
29
+ param :edits, type: "array",
30
+ desc: "Array of edits, each an object: { old_string, new_string, replace_all? }. " \
31
+ "Applied in order.",
32
+ required: true
33
+
34
+ def execute(path:, edits:)
35
+ return error("edits must be a non-empty array", code: :bad_edits) unless edits.is_a?(Array) && !edits.empty?
36
+
37
+ jail = Safety::PathJail.new(config.fs_root)
38
+ real = jail.resolve(path)
39
+ return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
40
+ return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
41
+
42
+ original = File.read(real).scrub
43
+ content = original
44
+ total = 0
45
+
46
+ edits.each_with_index do |edit, i|
47
+ content, n = apply_edit(content, edit, i + 1, path)
48
+ total += n
49
+ end
50
+
51
+ File.write(real, content)
52
+ "Applied #{edits.size} edit#{edits.size == 1 ? '' : 's'} to #{path} " \
53
+ "(#{total} replacement#{total == 1 ? '' : 's'}, #{original.bytesize} -> #{content.bytesize} bytes)"
54
+ rescue Safety::PathJail::Jailbreak => e
55
+ error(e.message, code: :path_denied)
56
+ rescue BadEdit => e
57
+ error(e.message, code: :edit_failed)
58
+ end
59
+
60
+ private
61
+
62
+ def apply_edit(content, edit, index, path)
63
+ old_s = fetch(edit, "old_string").to_s
64
+ raise BadEdit, "edit #{index}: new_string is required" unless has_key?(edit, "new_string")
65
+
66
+ new_s = fetch(edit, "new_string").to_s
67
+ replace_all = [true, "true"].include?(fetch(edit, "replace_all"))
68
+
69
+ raise BadEdit, "edit #{index}: old_string must not be empty" if old_s.empty?
70
+ raise BadEdit, "edit #{index}: old_string and new_string are identical" if old_s == new_s
71
+
72
+ if replace_all
73
+ count = 0
74
+ updated = content.gsub(old_s) { count += 1; new_s }
75
+ raise BadEdit, "edit #{index}: old_string not found in #{path}" if count.zero?
76
+
77
+ [updated, count]
78
+ else
79
+ count = content.scan(Regexp.new(Regexp.escape(old_s))).size
80
+ raise BadEdit, "edit #{index}: old_string not found in #{path}" if count.zero?
81
+ if count > 1
82
+ raise BadEdit, "edit #{index}: old_string is ambiguous (#{count} matches in #{path}); " \
83
+ "add surrounding context or set replace_all"
84
+ end
85
+
86
+ [content.sub(old_s) { new_s }, 1]
87
+ end
88
+ end
89
+
90
+ def indifferent_key(edit, key)
91
+ return nil unless edit.is_a?(Hash)
92
+ return key if edit.key?(key)
93
+ return key.to_sym if edit.key?(key.to_sym)
94
+ end
95
+
96
+ def fetch(edit, key)
97
+ k = indifferent_key(edit, key)
98
+ k ? edit[k] : nil
99
+ end
100
+
101
+ def has_key?(edit, key)
102
+ !indifferent_key(edit, key).nil?
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/ruby_outline"
5
+ require "ruby_llm/toolbox/safety/path_jail"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # SAFE. Produces a structural outline of a Ruby file (classes, modules,
11
+ # methods, constants) with line numbers, or finds where a name is defined.
12
+ # In-process via Ripper — it parses, never executes, the code.
13
+ class ParseRuby < Base
14
+ description "Outline a Ruby file's structure (classes, modules, methods, constants with line " \
15
+ "numbers), or locate definitions. Give a query to find definitions whose name " \
16
+ "matches, and/or a kind to filter. Parses in-process; does not run the code."
17
+
18
+ KINDS = %w[class module method constant].freeze
19
+ MAX_BYTES = 5 * 1024 * 1024
20
+
21
+ param :path, type: "string",
22
+ desc: "Ruby file to outline, relative to fs_root.",
23
+ required: true
24
+ param :query, type: "string",
25
+ desc: "Only show definitions whose (qualified) name matches this, case-insensitive. Optional.",
26
+ required: false
27
+ param :kind, type: "string",
28
+ desc: "Filter to a kind: class, module, method, or constant. Optional.",
29
+ required: false
30
+
31
+ def execute(path:, query: nil, kind: nil)
32
+ kind = kind.to_s.strip.downcase
33
+ kind = nil if kind.empty?
34
+ return error("unknown kind: #{kind} (use #{KINDS.join(', ')})", code: :bad_kind) if kind && !KINDS.include?(kind)
35
+
36
+ jail = Safety::PathJail.new(config.fs_root)
37
+ real = jail.resolve(path)
38
+ return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
39
+ return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
40
+
41
+ entries = RubyOutline.extract(File.read(real).scrub)
42
+ return "no definitions found in #{path}" if entries.empty?
43
+
44
+ truncate(render(entries, path, query, kind))
45
+ rescue Safety::PathJail::Jailbreak => e
46
+ error(e.message, code: :path_denied)
47
+ rescue RubyOutline::ParseError => e
48
+ error("#{e.message} in #{path}", code: :parse_error)
49
+ end
50
+
51
+ private
52
+
53
+ def render(entries, path, query, kind)
54
+ if query || kind
55
+ flat_list(entries, path, query, kind)
56
+ else
57
+ "Outline of #{path}:\n#{tree(entries)}"
58
+ end
59
+ end
60
+
61
+ def tree(entries)
62
+ entries.map { |e| "#{' ' * e.depth}#{label(e)} (L#{e.line})" }.join("\n")
63
+ end
64
+
65
+ def flat_list(entries, path, query, kind)
66
+ pairs = qualify(entries)
67
+ pairs = pairs.select { |e, _| e.kind.to_s == kind } if kind
68
+ if query
69
+ q = query.downcase
70
+ pairs = pairs.select { |e, qn| qn.downcase.include?(q) || e.name.downcase.include?(q) }
71
+ end
72
+ return "no matching definitions in #{path}" if pairs.empty?
73
+
74
+ desc = ["definitions", ("matching #{query.inspect}" if query), ("of kind #{kind}" if kind)].compact.join(" ")
75
+ header = "#{pairs.size} #{desc} in #{path}:"
76
+ "#{header}\n#{pairs.map { |e, qn| "#{qn} (L#{e.line}) [#{e.kind}]" }.join("\n")}"
77
+ end
78
+
79
+ # Pair each entry with its fully-qualified name using the depth stack.
80
+ def qualify(entries)
81
+ namespace = []
82
+ entries.map do |entry|
83
+ namespace = namespace.first(entry.depth)
84
+ ns = namespace.compact
85
+ qualified =
86
+ case entry.kind
87
+ when :class, :module, :singleton_class
88
+ full = (ns + [entry.name]).join("::")
89
+ namespace[entry.depth] = entry.name
90
+ full
91
+ when :method
92
+ ns.empty? ? entry.name : "#{ns.join('::')}##{entry.name}"
93
+ else # constant
94
+ ns.empty? ? entry.name : "#{ns.join('::')}::#{entry.name}"
95
+ end
96
+ [entry, qualified]
97
+ end
98
+ end
99
+
100
+ def label(entry)
101
+ case entry.kind
102
+ when :class, :singleton_class then "class #{entry.name}"
103
+ when :module then "module #{entry.name}"
104
+ when :method then "def #{entry.name}"
105
+ else entry.name
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/process_registry"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE. Stops a background process: SIGTERM to its process group, escalating
10
+ # to SIGKILL if it doesn't exit, then returns any final output. Always
11
+ # available (even if exec tools are later disabled) so a runaway can always
12
+ # be stopped. Removes the process from the registry.
13
+ class ProcessKill < Base
14
+ description "Stop a background process (started with process_start) and return any final output. " \
15
+ "Terminates the whole process group, escalating to SIGKILL if needed."
16
+
17
+ param :id, type: "string",
18
+ desc: "The process id to kill (e.g. 'proc_1').",
19
+ required: true
20
+
21
+ def execute(id:)
22
+ proc = ProcessRegistry.get(id)
23
+ return error("no such process: #{id}", code: :not_found) unless proc
24
+
25
+ was_running = proc.running?
26
+ proc.kill
27
+ final = proc.read_new
28
+ ProcessRegistry.delete(id)
29
+
30
+ body = +"#{id} #{was_running ? 'terminated' : 'was already exited'}"
31
+ body << " (exit #{proc.exit_code})" if proc.exit_code
32
+ unless final[:out].empty? && final[:err].empty?
33
+ body << "\n--- final stdout ---\n#{final[:out]}" unless final[:out].empty?
34
+ body << "\n--- final stderr ---\n#{final[:err]}" unless final[:err].empty?
35
+ end
36
+ truncate(body)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/process_registry"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE. Lists the background processes started this session, with their id,
10
+ # status, pid, age, and command — so an agent can rediscover ids and see
11
+ # what's still running.
12
+ class ProcessList < Base
13
+ description "List background processes (started with process_start): id, status, pid, age, and " \
14
+ "command. Read-only."
15
+
16
+ def execute
17
+ procs = ProcessRegistry.all
18
+ return "no background processes" if procs.empty?
19
+
20
+ lines = procs.map do |proc|
21
+ status = proc.status == :exited ? "exited(#{proc.exit_code})" : "running"
22
+ "#{proc.id} #{status.ljust(12)} pid=#{proc.pid} age=#{proc.age.round}s #{proc.name} #{proc.argv.inspect}"
23
+ end
24
+ truncate(([+"#{procs.size} process#{procs.size == 1 ? '' : 'es'}:"] + lines).join("\n"))
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/process_registry"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE. Returns the output a background process has produced since the last
10
+ # read, plus its current status (running, or exited with a code). Reads are
11
+ # incremental, so polling in a loop streams the process's output without
12
+ # repeats.
13
+ class ProcessOutput < Base
14
+ description "Read new stdout/stderr from a background process (started with process_start) since " \
15
+ "the last read, along with its status and exit code if it has finished. Poll this to " \
16
+ "follow a process's output."
17
+
18
+ param :id, type: "string",
19
+ desc: "The process id returned by process_start (e.g. 'proc_1').",
20
+ required: true
21
+
22
+ def execute(id:)
23
+ proc = ProcessRegistry.get(id)
24
+ return error("no such process: #{id}", code: :not_found) unless proc
25
+
26
+ data = proc.read_new
27
+ truncate(format_output(proc, data))
28
+ end
29
+
30
+ private
31
+
32
+ def format_output(proc, data)
33
+ body = +"#{proc.id} (#{proc.name}): #{proc.status}"
34
+ body << " (exit #{proc.exit_code})" if proc.status == :exited && proc.exit_code
35
+ body << "\n"
36
+
37
+ if data[:out].empty? && data[:err].empty?
38
+ body << "(no new output)"
39
+ return body
40
+ end
41
+
42
+ unless data[:out].empty?
43
+ body << "\n--- stdout#{data[:out_dropped] ? ' (earlier output dropped)' : ''} ---\n"
44
+ body << data[:out]
45
+ end
46
+ unless data[:err].empty?
47
+ body << "\n--- stderr#{data[:err_dropped] ? ' (earlier output dropped)' : ''} ---\n"
48
+ body << data[:err]
49
+ end
50
+ body
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/process_registry"
5
+ require "ruby_llm/toolbox/safety/command_guard"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # EXEC. Starts a long-running background process (a dev server, a file
11
+ # watcher, a log tail) and returns its id immediately instead of blocking.
12
+ # Read its output later with process_output, see everything running with
13
+ # process_list, and stop it with process_kill.
14
+ #
15
+ # Same safety model as bash: one allowlisted executable, argv only (no
16
+ # shell), the minimal env_passthrough environment, run in fs_root, in its
17
+ # own process group with an address-space cap. Concurrency is bounded by
18
+ # config.max_processes.
19
+ class ProcessStart < Base
20
+ exec_tool!
21
+
22
+ description "Start a long-running command as a background process and return its id (does not " \
23
+ "block). Use for dev servers, watchers, or log tails. The executable must be on the " \
24
+ "allowlist; no shell is involved. Manage it with process_output / process_list / " \
25
+ "process_kill."
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 :name, type: "string",
34
+ desc: "A short label for this process, shown in listings. Optional.",
35
+ required: false
36
+ param :unsafe, type: "boolean",
37
+ desc: "Request bypassing the command allowlist (still argv only, no shell). Only " \
38
+ "takes effect if an operator enabled allow_unsafe; otherwise refused. Default false.",
39
+ required: false
40
+
41
+ def execute(command:, args: nil, name: nil, unsafe: false)
42
+ exe = resolve_command(command, unsafe)
43
+ argv = [exe, *sanitize_args(args)]
44
+
45
+ id = ProcessRegistry.start(
46
+ argv: argv,
47
+ env: clean_env,
48
+ chdir: config.fs_root,
49
+ name: (name.to_s.empty? ? exe : name.to_s),
50
+ rlimits: memory_rlimits,
51
+ max: config.max_processes
52
+ )
53
+ proc = ProcessRegistry.get(id)
54
+ "Started #{id} (pid #{proc.pid}): #{argv.inspect}\nUse process_output id:#{id.inspect} to read its output."
55
+ rescue Safety::CommandGuard::Blocked => e
56
+ error(e.message, code: :command_denied)
57
+ rescue ProcessRegistry::LimitError => e
58
+ error(e.message, code: :too_many_processes)
59
+ end
60
+
61
+ private
62
+
63
+ def resolve_command(command, unsafe)
64
+ return Safety::CommandGuard.new(config.allowed_commands).check!(command) unless permit_unsafe!(unsafe, command)
65
+
66
+ cmd = command.to_s
67
+ raise Safety::CommandGuard::Blocked, "command is empty" if cmd.strip.empty?
68
+ raise Safety::CommandGuard::Blocked, "command contains a NUL byte" if cmd.include?("\u0000")
69
+
70
+ cmd
71
+ end
72
+
73
+ def sanitize_args(args)
74
+ Array(args).map do |arg|
75
+ str = arg.to_s
76
+ raise Safety::CommandGuard::Blocked, "argument contains a NUL byte" if str.include?("\u0000")
77
+
78
+ str
79
+ end
80
+ end
81
+
82
+ def clean_env
83
+ config.env_passthrough.each_with_object({}) do |key, env|
84
+ value = ENV[key]
85
+ env[key] = value unless value.nil?
86
+ end
87
+ end
88
+
89
+ # An address-space cap so a runaway can't exhaust host memory. No CPU
90
+ # cap — background processes are expected to run indefinitely.
91
+ def memory_rlimits
92
+ return {} unless /linux/i.match?(RUBY_PLATFORM)
93
+
94
+ bytes = parse_memory_bytes(config.sandbox_memory)
95
+ bytes ? { rlimit_as: bytes } : {}
96
+ end
97
+
98
+ def parse_memory_bytes(value)
99
+ str = value.to_s.strip.downcase
100
+ return nil if str.empty?
101
+ return nil unless (m = str.match(/\A(\d+)\s*([kmg])?b?\z/))
102
+
103
+ n = m[1].to_i
104
+ { "k" => 1024, "m" => 1024**2, "g" => 1024**3 }.fetch(m[2], 1) * n
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,77 @@
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 the project's Python tests (pytest or unittest) from fs_root
10
+ # and returns the output with a pass/fail headline. A failing suite is a
11
+ # normal result, not a tool error. Runs in the project environment, so a
12
+ # virtualenv/installed deps resolve as they would in a shell.
13
+ class PythonTests < Base
14
+ include ToolchainHelpers
15
+ exec_tool!
16
+
17
+ description "Run the project's Python test suite from fs_root and report results. Uses pytest " \
18
+ "by default, or unittest (python -m unittest discover) when framework is unittest. " \
19
+ "Optionally scope to a path."
20
+
21
+ FRAMEWORKS = %w[pytest unittest].freeze
22
+
23
+ param :path, type: "string",
24
+ desc: "Limit the run to this test file or directory, relative to fs_root. Optional.",
25
+ required: false
26
+ param :framework, type: "string",
27
+ desc: "pytest (default) or unittest.",
28
+ required: false
29
+
30
+ def execute(path: nil, framework: "pytest")
31
+ fw = framework.to_s.strip.downcase
32
+ fw = "pytest" if fw.empty?
33
+ return error("unknown framework: #{fw} (use #{FRAMEWORKS.join(', ')})", code: :bad_framework) unless FRAMEWORKS.include?(fw)
34
+
35
+ rel = jail_relative(path)
36
+ out, err, status = run_in_project(args_for(fw, rel), use_bundle: false)
37
+ toolchain_output(out, err, status,
38
+ pass_label: summarize(out, err, "TESTS PASSED"),
39
+ fail_label: summarize(out, err, "TESTS FAILED"))
40
+ rescue Safety::PathJail::Jailbreak => e
41
+ error(e.message, code: :path_denied)
42
+ rescue CommandMissing => e
43
+ error("#{e.message} is not available (is it installed in the project env?)", code: :unavailable)
44
+ end
45
+
46
+ private
47
+
48
+ def args_for(framework, rel)
49
+ if framework == "unittest"
50
+ args = ["python3", "-m", "unittest", "discover"]
51
+ args += ["-s", rel] if rel
52
+ else
53
+ args = ["pytest"]
54
+ args << rel if rel
55
+ end
56
+ args
57
+ end
58
+
59
+ # Surface pytest's or unittest's summary in the headline.
60
+ def summarize(out, err, label)
61
+ text = "#{out}\n#{err}"
62
+ counts = %w[passed failed error skipped].filter_map do |word|
63
+ m = text.match(/(\d+) #{word}/)
64
+ "#{m[1]} #{word}" if m
65
+ end
66
+ return "#{label} (#{counts.join(', ')})" unless counts.empty?
67
+
68
+ if (m = text.match(/Ran (\d+) tests?.*?(OK|FAILED)/m))
69
+ return "#{label} (ran #{m[1]}, #{m[2]})"
70
+ end
71
+
72
+ label
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/safety/path_jail"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # SAFE reference tool. Read-only, confined to fs_root, output budgeted.
10
+ # Loaded and usable by default.
11
+ class ReadFile < Base
12
+ description "Read a UTF-8 text file from within the configured fs_root. " \
13
+ "Optionally restrict to a 1-based line range, or return only the last N lines with tail. " \
14
+ "Large output is automatically truncated to fit a token budget."
15
+
16
+ param :path, type: "string",
17
+ desc: "File path, relative to fs_root (or an absolute path inside it).",
18
+ required: true
19
+ param :start_line, type: "integer",
20
+ desc: "1-based first line to include. Optional.",
21
+ required: false
22
+ param :end_line, type: "integer",
23
+ desc: "1-based last line to include, inclusive. Optional.",
24
+ required: false
25
+ param :tail, type: "integer",
26
+ desc: "Return only the last N lines (like `tail -n N`). Takes precedence over " \
27
+ "start_line/end_line. Optional.",
28
+ required: false
29
+ param :unsafe, type: "boolean",
30
+ desc: "Bypass the fs_root jail (read anywhere). Only honored if the operator " \
31
+ "enabled allow_unsafe; otherwise the call is refused. Default false.",
32
+ required: false
33
+
34
+ MAX_BYTES = 5 * 1024 * 1024 # refuse to slurp anything enormous
35
+
36
+ def execute(path:, start_line: nil, end_line: nil, tail: nil, unsafe: false)
37
+ jail = path_jail(unsafe: unsafe, detail: path)
38
+ real = jail.resolve(path)
39
+
40
+ return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
41
+ if File.size(real) > MAX_BYTES
42
+ return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large)
43
+ end
44
+
45
+ lines = File.readlines(real)
46
+ selected = if tail && tail.to_i.positive?
47
+ lines.last(tail.to_i)
48
+ else
49
+ slice_lines(lines, start_line, end_line)
50
+ end
51
+ return error("no lines in the given range", code: :empty_range) if selected.nil?
52
+
53
+ truncate(selected.join.scrub)
54
+ rescue Safety::PathJail::Jailbreak => e
55
+ error(e.message, code: :path_denied)
56
+ end
57
+
58
+ private
59
+
60
+ def slice_lines(lines, start_line, end_line)
61
+ return lines if start_line.nil? && end_line.nil?
62
+
63
+ first = (start_line || 1).to_i
64
+ last = (end_line || lines.length).to_i
65
+ return nil if first < 1 || first > lines.length
66
+
67
+ last = lines.length if last > lines.length
68
+ return nil if last < first
69
+
70
+ lines[(first - 1)..(last - 1)]
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end