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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "pathname"
5
+ require "ruby_llm/toolbox/base"
6
+ require "ruby_llm/toolbox/safety/path_jail"
7
+
8
+ module RubyLLM
9
+ module Toolbox
10
+ module Tools
11
+ # SAFE. Searches file contents for a regular expression within fs_root and
12
+ # returns "path:line: text" hits.
13
+ #
14
+ # The pattern comes from the model, so two abuse vectors are guarded:
15
+ # - ReDoS: the regex is compiled with a per-match timeout (Ruby 3.2+),
16
+ # so catastrophic backtracking can't hang the process.
17
+ # - Traversal: the walk starts at the jailed root, prunes noisy/VCS
18
+ # directories, and skips symlinks so it can't be led outside.
19
+ class GrepFiles < Base
20
+ description "Search file contents for a regular expression within fs_root. Returns " \
21
+ "'path:line: text' for each match. Optionally restrict to files whose name " \
22
+ "matches a glob, and/or match case-insensitively. Results are capped, " \
23
+ "ReDoS-guarded, and token-budgeted."
24
+
25
+ param :pattern, type: "string",
26
+ desc: "Regular expression to search for.",
27
+ required: true
28
+ param :path, type: "string",
29
+ desc: "Directory to search, relative to fs_root. Defaults to fs_root.",
30
+ required: false
31
+ param :glob, type: "string",
32
+ desc: "Only search files whose basename matches this glob, e.g. '*.rb'. Optional.",
33
+ required: false
34
+ param :ignore_case, type: "boolean",
35
+ desc: "Case-insensitive match. Default false.",
36
+ required: false
37
+ param :context, type: "integer",
38
+ desc: "Lines of context to show before AND after each match (like grep -C). Optional.",
39
+ required: false
40
+ param :before, type: "integer",
41
+ desc: "Lines of context before each match (like grep -B). Overrides context. Optional.",
42
+ required: false
43
+ param :after, type: "integer",
44
+ desc: "Lines of context after each match (like grep -A). Overrides context. Optional.",
45
+ required: false
46
+
47
+ MAX_FILE_BYTES = 5 * 1024 * 1024
48
+ MAX_CONTEXT = 50
49
+
50
+ def execute(pattern:, path: ".", glob: nil, ignore_case: false, context: nil, before: nil, after: nil)
51
+ regex = build_regex(pattern, ignore_case)
52
+ jail = Safety::PathJail.new(config.fs_root)
53
+ root = jail.resolve(path)
54
+ return error("not a directory: #{path}", code: :not_a_directory) unless File.directory?(root)
55
+
56
+ ctx_before = clamp_context(before.nil? ? context : before)
57
+ ctx_after = clamp_context(after.nil? ? context : after)
58
+
59
+ if ctx_before.zero? && ctx_after.zero?
60
+ matches, capped = gather(root, regex, glob)
61
+ return "no matches for #{pattern.inspect}" if matches.empty?
62
+
63
+ body = +"#{matches.size}#{capped ? '+' : ''} match#{matches.size == 1 ? '' : 'es'} " \
64
+ "for #{pattern.inspect}:\n"
65
+ body << matches.join("\n")
66
+ truncate(body)
67
+ else
68
+ blocks, total, capped = gather_with_context(root, regex, glob, ctx_before, ctx_after)
69
+ return "no matches for #{pattern.inspect}" if total.zero?
70
+
71
+ body = +"#{total}#{capped ? '+' : ''} match#{total == 1 ? '' : 'es'} " \
72
+ "for #{pattern.inspect}:\n"
73
+ body << blocks.join("\n--\n")
74
+ truncate(body)
75
+ end
76
+ rescue Safety::PathJail::Jailbreak => e
77
+ error(e.message, code: :path_denied)
78
+ rescue Regexp::TimeoutError
79
+ error("regex timed out (possible catastrophic backtracking); simplify the pattern",
80
+ code: :regex_timeout)
81
+ rescue RegexpError => e
82
+ error("invalid regex: #{e.message}", code: :bad_pattern)
83
+ end
84
+
85
+ private
86
+
87
+ def clamp_context(value)
88
+ n = value.to_i
89
+ return 0 if n <= 0
90
+
91
+ [n, MAX_CONTEXT].min
92
+ end
93
+
94
+ def build_regex(pattern, ignore_case)
95
+ options = ignore_case ? Regexp::IGNORECASE : 0
96
+ Regexp.new(pattern.to_s, options, timeout: config.regex_timeout)
97
+ end
98
+
99
+ def gather(root, regex, glob)
100
+ base = Pathname.new(root)
101
+ matches = []
102
+ capped = false
103
+
104
+ catch(:done) do
105
+ walk(root, glob) do |file|
106
+ scan(file, regex) do |lineno, text|
107
+ rel = Pathname.new(file).relative_path_from(base).to_s
108
+ matches << "#{rel}:#{lineno}: #{text.strip}"
109
+ if matches.size >= config.max_grep_matches
110
+ capped = true
111
+ throw :done
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ [matches, capped]
118
+ end
119
+
120
+ # Like gather, but expands each match with before/after context lines,
121
+ # merges overlapping/adjacent ranges per file, and returns formatted
122
+ # blocks (joined later with grep-style "--" separators). Match lines use
123
+ # "path:line: text"; context lines use "path-line- text".
124
+ def gather_with_context(root, regex, glob, ctx_before, ctx_after)
125
+ base = Pathname.new(root)
126
+ blocks = []
127
+ total = 0
128
+ capped = false
129
+ cap = config.max_grep_matches
130
+
131
+ catch(:done) do
132
+ walk(root, glob) do |file|
133
+ next if binary?(file)
134
+
135
+ lines = read_lines(file)
136
+ next if lines.nil?
137
+
138
+ match_idxs = []
139
+ lines.each_with_index { |line, i| match_idxs << i if regex.match?(line) }
140
+ next if match_idxs.empty?
141
+
142
+ taken = match_idxs.first(cap - total)
143
+ capped = true if taken.size < match_idxs.size
144
+ match_set = {}
145
+ taken.each { |i| match_set[i] = true }
146
+
147
+ rel = Pathname.new(file).relative_path_from(base).to_s
148
+ ranges = taken.map { |i| [[i - ctx_before, 0].max, [i + ctx_after, lines.size - 1].min] }
149
+ merge_ranges(ranges).each do |lo, hi|
150
+ block = (lo..hi).map do |i|
151
+ sep = match_set[i] ? ":" : "-"
152
+ "#{rel}#{sep}#{i + 1}#{sep} #{lines[i].to_s.strip}"
153
+ end
154
+ blocks << block.join("\n")
155
+ end
156
+
157
+ total += taken.size
158
+ throw :done if total >= cap
159
+ end
160
+ end
161
+
162
+ [blocks, total, capped]
163
+ end
164
+
165
+ def read_lines(file)
166
+ File.readlines(file).map(&:scrub)
167
+ rescue ArgumentError, SystemCallError
168
+ nil
169
+ end
170
+
171
+ def merge_ranges(ranges)
172
+ merged = []
173
+ ranges.sort_by(&:first).each do |lo, hi|
174
+ if !merged.empty? && lo <= merged.last[1] + 1
175
+ merged.last[1] = [merged.last[1], hi].max
176
+ else
177
+ merged << [lo, hi]
178
+ end
179
+ end
180
+ merged
181
+ end
182
+
183
+ def walk(root, glob)
184
+ Find.find(root) do |entry|
185
+ basename = File.basename(entry)
186
+
187
+ if File.directory?(entry)
188
+ Find.prune if config.ignored_dirs.include?(basename)
189
+ next
190
+ end
191
+
192
+ next if File.symlink?(entry)
193
+ next if glob && !File.fnmatch?(glob, basename, File::FNM_PATHNAME)
194
+ next unless File.file?(entry)
195
+ next if File.size(entry) > MAX_FILE_BYTES
196
+
197
+ yield entry
198
+ end
199
+ end
200
+
201
+ def scan(file, regex)
202
+ return if binary?(file)
203
+
204
+ File.foreach(file).with_index(1) do |line, lineno|
205
+ safe = line.scrub
206
+ yield(lineno, safe) if regex.match?(safe)
207
+ end
208
+ rescue ArgumentError
209
+ # Unreadable as text (encoding) — skip the file rather than fail.
210
+ nil
211
+ end
212
+
213
+ def binary?(file)
214
+ File.binread(file, 1024).to_s.include?("\u0000".b)
215
+ rescue StandardError
216
+ false
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "ruby_llm/toolbox/safety/url_guard"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # Shared guarded-HTTP behavior for web_fetch and http_request. With the
11
+ # guard on (the default), every request URL and every redirect hop passes
12
+ # through UrlGuard, and the socket is pinned to the vetted IP so a second
13
+ # DNS lookup can't redirect us to an internal address. With guard: false
14
+ # (only reachable via an operator-permitted unsafe override), the URL is
15
+ # fetched directly with no SSRF checks.
16
+ module HttpHelpers
17
+ Response = Struct.new(:status, :headers, :body, :final_url, keyword_init: true)
18
+
19
+ class FetchError < StandardError; end
20
+
21
+ def url_guard
22
+ Safety::UrlGuard.new(allowlist: config.web_allowlist, denylist: config.web_denylist)
23
+ end
24
+
25
+ # GET with redirect following and a body-size cap. Each hop is re-guarded
26
+ # and re-pinned when guard is true.
27
+ def guarded_get(url, guard: true)
28
+ checker = url_guard if guard
29
+ current = url
30
+ seen = 0
31
+
32
+ loop do
33
+ uri, pin = resolve_hop(checker, current, guard)
34
+ response, body = perform(uri, "GET", nil, nil, pin)
35
+
36
+ if redirect?(response)
37
+ location = response["location"]
38
+ raise FetchError, "redirect with no Location header (status #{response.code})" unless location
39
+ raise FetchError, "too many redirects (max #{config.max_redirects})" if seen >= config.max_redirects
40
+
41
+ seen += 1
42
+ current = URI.join(uri.to_s, location).to_s
43
+ next
44
+ end
45
+
46
+ return Response.new(status: response.code.to_i, headers: response.to_hash,
47
+ body: body, final_url: uri.to_s)
48
+ end
49
+ rescue Safety::UrlGuard::Blocked
50
+ raise
51
+ rescue StandardError => e
52
+ raise FetchError, e.message
53
+ end
54
+
55
+ # A single request with an explicit method/headers/body (no redirect
56
+ # following). Used by http_request.
57
+ def guarded_request(method, url, headers: {}, body: nil, guard: true)
58
+ uri, pin = resolve_hop(url_guard, url, guard)
59
+ response, raw = perform(uri, method.to_s.upcase, headers, body, pin)
60
+ Response.new(status: response.code.to_i, headers: response.to_hash, body: raw, final_url: uri.to_s)
61
+ rescue Safety::UrlGuard::Blocked
62
+ raise
63
+ rescue StandardError => e
64
+ raise FetchError, e.message
65
+ end
66
+
67
+ private
68
+
69
+ # Returns [uri, pinned_ip_or_nil]. When guarded, UrlGuard vets the host
70
+ # and supplies the IP to pin; when not, we only enforce http/https.
71
+ def resolve_hop(checker, url, guard)
72
+ if guard
73
+ resolution = checker.resolve!(url)
74
+ [resolution.uri, resolution.address]
75
+ else
76
+ uri = URI.parse(url.to_s)
77
+ raise FetchError, "only http/https URLs are allowed" unless %w[http https].include?(uri.scheme)
78
+
79
+ [uri, nil]
80
+ end
81
+ end
82
+
83
+ def redirect?(response)
84
+ response.is_a?(Net::HTTPRedirection)
85
+ end
86
+
87
+ def perform(uri, method, headers, body, pin = nil)
88
+ request = build_request(uri, method, headers, body)
89
+
90
+ http = Net::HTTP.new(uri.host, uri.port)
91
+ http.use_ssl = uri.scheme == "https"
92
+ http.ipaddr = pin if pin # pin to the vetted address (closes DNS rebinding)
93
+ http.open_timeout = config.http_timeout
94
+ http.read_timeout = config.http_timeout
95
+
96
+ captured = +""
97
+ response = http.start do |conn|
98
+ conn.request(request) do |res|
99
+ unless res.is_a?(Net::HTTPRedirection)
100
+ res.read_body do |chunk|
101
+ captured << chunk
102
+ break if captured.bytesize >= config.max_fetch_bytes
103
+ end
104
+ end
105
+ res
106
+ end
107
+ end
108
+
109
+ [response, captured]
110
+ end
111
+
112
+ def build_request(uri, method, headers, body)
113
+ klass = begin
114
+ Net::HTTP.const_get(method.capitalize)
115
+ rescue NameError
116
+ nil
117
+ end
118
+ raise FetchError, "unsupported HTTP method: #{method}" unless klass && klass <= Net::HTTPRequest
119
+
120
+ request = klass.new(uri)
121
+ request["User-Agent"] = config.user_agent
122
+ request["Accept"] ||= "*/*"
123
+ Array(headers).each { |k, v| request[k.to_s] = v.to_s } if headers
124
+ request.body = body.to_s if body && !body.to_s.empty?
125
+ request
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm/toolbox/base"
4
+ require "ruby_llm/toolbox/tools/http_helpers"
5
+
6
+ module RubyLLM
7
+ module Toolbox
8
+ module Tools
9
+ # A general HTTP client, UrlGuard-protected. Read methods (GET/HEAD) work
10
+ # by default; mutating methods (POST/PUT/PATCH/DELETE) require
11
+ # enable_exec_tools, so the safe default can't change remote state. Returns
12
+ # status, key headers, and the (token-budgeted) body.
13
+ class HttpRequest < Base
14
+ include HttpHelpers
15
+
16
+ description "Make an HTTP request to an http/https URL and return the status, headers, and " \
17
+ "body. GET and HEAD are available by default; POST/PUT/PATCH/DELETE require exec " \
18
+ "tools to be enabled. Cannot reach private, loopback, or cloud-metadata addresses."
19
+
20
+ READ_METHODS = %w[GET HEAD].freeze
21
+ MUTATING_METHODS = %w[POST PUT PATCH DELETE].freeze
22
+ SHOWN_HEADERS = %w[content-type content-length location etag last-modified].freeze
23
+
24
+ param :url, type: "string",
25
+ desc: "The http/https URL to request.",
26
+ required: true
27
+ param :method, type: "string",
28
+ desc: "HTTP method: GET (default), HEAD, POST, PUT, PATCH, DELETE.",
29
+ required: false
30
+ param :headers, type: "object",
31
+ desc: "Optional request headers as a JSON object of name/value strings.",
32
+ required: false
33
+ param :body, type: "string",
34
+ desc: "Optional request body (for POST/PUT/PATCH).",
35
+ required: false
36
+ param :unsafe, type: "boolean",
37
+ desc: "Request bypassing SSRF protection (UrlGuard). Only takes effect if an " \
38
+ "operator enabled allow_unsafe; otherwise the call is refused. Default false.",
39
+ required: false
40
+
41
+ def execute(url:, method: "GET", headers: nil, body: nil, unsafe: false)
42
+ verb = method.to_s.strip.upcase
43
+ verb = "GET" if verb.empty?
44
+ return error("unsupported method: #{verb}", code: :bad_method) unless (READ_METHODS + MUTATING_METHODS).include?(verb)
45
+ if MUTATING_METHODS.include?(verb) && !config.enable_exec_tools
46
+ return error("#{verb} requires enable_exec_tools = true (mutating request)", code: :exec_disabled)
47
+ end
48
+
49
+ bypass = permit_unsafe!(unsafe, url)
50
+ response = guarded_request(verb, url, headers: headers, body: body, guard: !bypass)
51
+ return error("HTTP #{response.status} from #{response.final_url}", code: :http_error) if response.status >= 400
52
+
53
+ truncate(format_response(verb, response))
54
+ rescue Safety::UrlGuard::Blocked => e
55
+ error(e.message, code: :url_blocked)
56
+ rescue HttpHelpers::FetchError => e
57
+ error("request failed: #{e.message}", code: :request_failed)
58
+ end
59
+
60
+ private
61
+
62
+ def format_response(verb, response)
63
+ lines = ["#{verb} #{response.final_url} -> #{response.status}"]
64
+ SHOWN_HEADERS.each do |name|
65
+ value = Array(response.headers[name]).first
66
+ lines << "#{name}: #{value}" if value
67
+ end
68
+ body = response.body.to_s.scrub
69
+ lines << "\n--- body ---\n#{body}" unless body.empty?
70
+ lines.join("\n")
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ruby_llm/toolbox/base"
5
+ require "ruby_llm/toolbox/data_path"
6
+ require "ruby_llm/toolbox/safety/path_jail"
7
+
8
+ module RubyLLM
9
+ module Toolbox
10
+ module Tools
11
+ # SAFE. Parses JSON (from a file in fs_root or an inline string) and either
12
+ # pretty-prints it or extracts a value with a dot/bracket path. In-process,
13
+ # read-only.
14
+ #
15
+ # Path syntax: dot-separated keys, [n] for array indices, and [] to map a
16
+ # field across an array. Examples:
17
+ # users[0].name users[].email config.server.port
18
+ class JsonQuery < Base
19
+ description "Query JSON with a path expression, or pretty-print it. Provide either a file " \
20
+ "path (within fs_root) or an inline json string. Path syntax: keys separated by " \
21
+ "dots, [n] for array index, [] to map over an array (e.g. users[].name)."
22
+
23
+ MAX_BYTES = 5 * 1024 * 1024
24
+
25
+ param :path, type: "string",
26
+ desc: "JSON file to read, relative to fs_root. Provide this or json.",
27
+ required: false
28
+ param :json, type: "string",
29
+ desc: "Inline JSON string. Provide this or path.",
30
+ required: false
31
+ param :query, type: "string",
32
+ desc: "Path expression to extract, e.g. 'users[0].name'. Omit to pretty-print. Optional.",
33
+ required: false
34
+
35
+ def execute(path: nil, json: nil, query: nil)
36
+ source = load_source(path, json)
37
+ return source if source.is_a?(Hash) # error
38
+
39
+ data = JSON.parse(source)
40
+ result = query.to_s.strip.empty? ? data : DataPath.query(data, query)
41
+ truncate(JSON.pretty_generate(result))
42
+ rescue Safety::PathJail::Jailbreak => e
43
+ error(e.message, code: :path_denied)
44
+ rescue JSON::ParserError => e
45
+ error("invalid JSON: #{e.message}", code: :bad_json)
46
+ rescue DataPath::Error => e
47
+ error(e.message, code: :bad_query)
48
+ end
49
+
50
+ private
51
+
52
+ def load_source(path, json)
53
+ if path && !path.to_s.empty?
54
+ jail = Safety::PathJail.new(config.fs_root)
55
+ real = jail.resolve(path)
56
+ return error("not a file: #{path}", code: :not_a_file) unless File.file?(real)
57
+ return error("file too large (> #{MAX_BYTES} bytes)", code: :too_large) if File.size(real) > MAX_BYTES
58
+
59
+ File.read(real).scrub
60
+ elsif json && !json.to_s.empty?
61
+ json.to_s
62
+ else
63
+ error("provide either path or json", code: :no_input)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,67 @@
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 RuboCop or Standard over the project at fs_root. Offenses are
10
+ # a normal result, not a tool error. With autocorrect, the linter rewrites
11
+ # files in place (a write effect — hence exec-gated).
12
+ class Lint < Base
13
+ include ToolchainHelpers
14
+ exec_tool!
15
+
16
+ description "Lint the project at fs_root with RuboCop or Standard. Auto-detects (Standard if " \
17
+ ".standard.yml is present, else RuboCop) or set linter explicitly. Optionally scope " \
18
+ "to a path and apply safe autocorrections. Uses `bundle exec` when a Gemfile exists."
19
+
20
+ LINTERS = %w[rubocop standard].freeze
21
+
22
+ param :path, type: "string",
23
+ desc: "Limit linting to this path, relative to fs_root. Optional.",
24
+ required: false
25
+ param :linter, type: "string",
26
+ desc: "Force the linter: rubocop or standard. Default: auto-detect.",
27
+ required: false
28
+ param :autocorrect, type: "boolean",
29
+ desc: "Apply safe autocorrections, rewriting files in place. Default false.",
30
+ required: false
31
+
32
+ def execute(path: nil, linter: nil, autocorrect: false)
33
+ tool = (linter || detect_linter).to_s
34
+ return error("unknown linter: #{tool} (use #{LINTERS.join(', ')})", code: :bad_linter) unless LINTERS.include?(tool)
35
+
36
+ rel = jail_relative(path)
37
+ out, err, status = run_in_project(build_args(tool, rel, autocorrect), use_bundle: true)
38
+ toolchain_output(out, err, status,
39
+ pass_label: "LINT CLEAN",
40
+ fail_label: autocorrect ? "LINT: offenses found (autocorrected where possible)" : "LINT: offenses found")
41
+ rescue Safety::PathJail::Jailbreak => e
42
+ error(e.message, code: :path_denied)
43
+ rescue CommandMissing => e
44
+ error("#{e.message} is not available (is it in the bundle / installed?)", code: :unavailable)
45
+ end
46
+
47
+ private
48
+
49
+ def detect_linter
50
+ File.exist?(File.join(config.fs_root, ".standard.yml")) ? "standard" : "rubocop"
51
+ end
52
+
53
+ def build_args(tool, rel, autocorrect)
54
+ if tool == "standard"
55
+ args = ["standardrb"]
56
+ args << "--fix" if autocorrect
57
+ else
58
+ args = ["rubocop", "--no-color"]
59
+ args << "--autocorrect" if autocorrect
60
+ end
61
+ args << rel if rel
62
+ args
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
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
+ # SAFE. Lists the entries of a directory within fs_root, with type and
11
+ # size. Symlinked directories are listed but not traversed, so a link
12
+ # can't be used to walk out of the jail.
13
+ class ListDirectory < Base
14
+ description "List the entries of a directory within fs_root, with each entry's " \
15
+ "type (dir/file/symlink) and size. Set recursive to walk subdirectories " \
16
+ "(results are capped and token-budgeted)."
17
+
18
+ param :path, type: "string",
19
+ desc: "Directory path relative to fs_root. Defaults to fs_root itself.",
20
+ required: false
21
+ param :recursive, type: "boolean",
22
+ desc: "Walk subdirectories. Default false.",
23
+ required: false
24
+ param :include_hidden, type: "boolean",
25
+ desc: "Include dotfiles and dot-directories. Default false.",
26
+ required: false
27
+
28
+ MAX_ENTRIES = 1_000
29
+
30
+ def execute(path: ".", recursive: false, include_hidden: false)
31
+ jail = Safety::PathJail.new(config.fs_root)
32
+ root = jail.resolve(path)
33
+ return error("not a directory: #{path}", code: :not_a_directory) unless File.directory?(root)
34
+
35
+ entries = collect(root, recursive: recursive, include_hidden: include_hidden)
36
+ capped = entries.first(MAX_ENTRIES)
37
+
38
+ body = +"#{entries.size} entr#{entries.size == 1 ? 'y' : 'ies'}"
39
+ body << " (showing first #{MAX_ENTRIES}; narrow the path)" if entries.size > MAX_ENTRIES
40
+ body << " in #{display(path)}:\n"
41
+ capped.each { |line| body << line << "\n" }
42
+ truncate(body)
43
+ rescue Safety::PathJail::Jailbreak => e
44
+ error(e.message, code: :path_denied)
45
+ end
46
+
47
+ private
48
+
49
+ def display(path)
50
+ path.nil? || path.empty? || path == "." ? "fs_root" : path
51
+ end
52
+
53
+ def collect(root, recursive:, include_hidden:)
54
+ base = Pathname.new(root)
55
+ pattern = recursive ? File.join(root, "**", "*") : File.join(root, "*")
56
+ flags = include_hidden ? File::FNM_DOTMATCH : 0
57
+
58
+ Dir.glob(pattern, flags).sort.filter_map do |entry|
59
+ basename = File.basename(entry)
60
+ next if basename == "." || basename == ".."
61
+
62
+ rel = base_relative(base, entry)
63
+ next if !include_hidden && rel.split(File::SEPARATOR).any? { |seg| seg.start_with?(".") }
64
+
65
+ describe(entry, rel)
66
+ end
67
+ end
68
+
69
+ def base_relative(base, entry)
70
+ Pathname.new(entry).relative_path_from(base).to_s
71
+ end
72
+
73
+ def describe(entry, rel)
74
+ if File.symlink?(entry)
75
+ "#{rel} -> (symlink)"
76
+ elsif File.directory?(entry)
77
+ "#{rel}/"
78
+ else
79
+ "#{rel} (#{File.size(entry)} B)"
80
+ end
81
+ rescue SystemCallError
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end