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,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
|