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,58 @@
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
+ # SAFE. Shows line-by-line authorship (commit, author, date) for a file in
10
+ # the repo at fs_root. Read-only.
11
+ class GitBlame < Base
12
+ include GitHelpers
13
+
14
+ description "Show git blame for a file in the repo at fs_root: which commit, author, and date " \
15
+ "last touched each line. Optionally limit to a line range. Read-only."
16
+
17
+ param :path, type: "string",
18
+ desc: "File to blame, relative to fs_root.",
19
+ required: true
20
+ param :start_line, type: "integer",
21
+ desc: "First line of the range to blame. Optional.",
22
+ required: false
23
+ param :end_line, type: "integer",
24
+ desc: "Last line of the range to blame. Optional.",
25
+ required: false
26
+
27
+ def execute(path:, start_line: nil, end_line: nil)
28
+ rel = repo_relative(path)
29
+ return error("path must be provided", code: :bad_path) if rel.nil?
30
+
31
+ args = ["blame", "--date=short"]
32
+ if (range = line_range(start_line, end_line))
33
+ args += ["-L", range]
34
+ end
35
+ args += ["--", rel]
36
+
37
+ out, err, status = run_git(*args)
38
+ result = git_result(out, err, status)
39
+ return result if result.is_a?(Hash)
40
+
41
+ truncate(result.strip.empty? ? "no blame output (empty file?)" : result)
42
+ rescue Safety::PathJail::Jailbreak => e
43
+ error(e.message, code: :path_denied)
44
+ end
45
+
46
+ private
47
+
48
+ def line_range(start_line, end_line)
49
+ return nil if start_line.nil? && end_line.nil?
50
+
51
+ first = [start_line.to_i, 1].max
52
+ last = end_line.nil? ? "" : [end_line.to_i, first].max
53
+ "#{first},#{last}"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,35 @@
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
+ # SAFE. Lists the branches of the repo at fs_root, with the current branch
10
+ # marked and each branch's latest commit. Read-only; does not create,
11
+ # switch, or delete branches (git_checkout handles switching/creating).
12
+ class GitBranch < Base
13
+ include GitHelpers
14
+
15
+ description "List the branches of the repo at fs_root, with the current branch marked (*) and " \
16
+ "each branch's tip commit. Set all true to include remote-tracking branches. Read-only."
17
+
18
+ param :all, type: "boolean",
19
+ desc: "Include remote-tracking branches. Default false (local only).",
20
+ required: false
21
+
22
+ def execute(all: false)
23
+ args = ["branch", "--no-color", "-vv"]
24
+ args << "-a" if all
25
+
26
+ out, err, status = run_git(*args)
27
+ result = git_result(out, err, status)
28
+ return result if result.is_a?(Hash)
29
+
30
+ truncate(result.strip.empty? ? "no branches yet (no commits?)" : result)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
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. Switches branches or creates a new branch in the repo at fs_root.
10
+ # The ref is validated (no leading dash, ref-safe characters only) so it
11
+ # can't smuggle git options.
12
+ class GitCheckout < Base
13
+ include GitHelpers
14
+ exec_tool!
15
+
16
+ description "Switch to a branch/commit in the repository at fs_root, or create a new branch " \
17
+ "with create: true. Note this can change the working tree."
18
+
19
+ param :ref, type: "string",
20
+ desc: "Branch, tag, or commit to switch to (or the new branch name when create is true).",
21
+ required: true
22
+ param :create, type: "boolean",
23
+ desc: "Create a new branch named ref before switching to it (git checkout -b). Default false.",
24
+ required: false
25
+
26
+ def execute(ref:, create: false)
27
+ return error("invalid ref: #{ref.inspect}", code: :bad_ref) unless valid_ref?(ref)
28
+
29
+ args = ["checkout"]
30
+ args << "-b" if create
31
+ args << ref
32
+
33
+ out, err, status = run_git(*args)
34
+ result = git_result(out, err, status)
35
+ return result if result.is_a?(Hash)
36
+
37
+ # git prints checkout feedback to stderr on success.
38
+ truncate([out, err].map(&:strip).reject(&:empty?).join("\n"))
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
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. Commits staged changes in the repo at fs_root. The message is
10
+ # passed as a single argv element (-m), so it is never parsed by a shell.
11
+ class GitCommit < Base
12
+ include GitHelpers
13
+ exec_tool!
14
+
15
+ description "Commit staged changes in the repository at fs_root with a message. Set all to " \
16
+ "also stage modified/deleted tracked files first (git commit -a). Does not push."
17
+
18
+ param :message, type: "string",
19
+ desc: "Commit message.",
20
+ required: true
21
+ param :all, type: "boolean",
22
+ desc: "Stage modified/deleted tracked files before committing (git commit -a). Default false.",
23
+ required: false
24
+
25
+ def execute(message:, all: false)
26
+ msg = message.to_s
27
+ return error("commit message must not be empty", code: :empty_message) if msg.strip.empty?
28
+
29
+ args = ["commit"]
30
+ args << "-a" if all
31
+ args += ["-m", msg]
32
+
33
+ out, err, status = run_git(*args)
34
+ result = git_result(out, err, status)
35
+ if result.is_a?(Hash)
36
+ combined = "#{out}\n#{err}"
37
+ return error("nothing to commit (stage changes first)", code: :nothing_to_commit) if combined.match?(/nothing to commit/i)
38
+
39
+ return result
40
+ end
41
+
42
+ truncate(out.strip)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,50 @@
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
+ # SAFE. Shows a unified diff of the repo at fs_root. Read-only.
10
+ #
11
+ # External diff drivers and textconv filters are disabled so a malicious
12
+ # repo can't turn a diff into command execution.
13
+ class GitDiff < Base
14
+ include GitHelpers
15
+
16
+ description "Show a unified git diff for the repository at fs_root. By default shows unstaged " \
17
+ "changes; set staged to show what's staged. Optionally scope to a path or diff " \
18
+ "against a ref. Read-only."
19
+
20
+ param :staged, type: "boolean",
21
+ desc: "Show staged changes (git diff --cached) instead of unstaged. Default false.",
22
+ required: false
23
+ param :path, type: "string",
24
+ desc: "Limit the diff to this path, relative to fs_root. Optional.",
25
+ required: false
26
+ param :ref, type: "string",
27
+ desc: "Diff against this commit/branch/tag instead of the index. Optional.",
28
+ required: false
29
+
30
+ def execute(staged: false, path: nil, ref: nil)
31
+ return error("invalid ref: #{ref.inspect}", code: :bad_ref) if ref && !valid_ref?(ref)
32
+
33
+ rel = repo_relative(path)
34
+ args = ["diff", "--no-ext-diff", "--no-textconv", "--stat", "--patch"]
35
+ args << "--cached" if staged
36
+ args << ref if ref
37
+ args += ["--", rel] if rel
38
+
39
+ out, err, status = run_git(*args)
40
+ result = git_result(out, err, status)
41
+ return result if result.is_a?(Hash)
42
+
43
+ truncate(result.strip.empty? ? "no changes" : result)
44
+ rescue Safety::PathJail::Jailbreak => e
45
+ error(e.message, code: :path_denied)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,66 @@
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
+ # SAFE. Searches the repo's tracked files with `git grep` and returns
10
+ # matching lines with file:line prefixes. Read-only. Faster and cleaner
11
+ # than grep_files in a git repo because it only looks at tracked content
12
+ # and skips binary files.
13
+ class GitGrep < Base
14
+ include GitHelpers
15
+
16
+ description "Search the tracked files of the repo at fs_root for a pattern using git grep, " \
17
+ "returning file:line: matches. Optionally restrict to a path, ignore case, or treat " \
18
+ "the pattern as a fixed string instead of a regex."
19
+
20
+ param :pattern, type: "string",
21
+ desc: "The pattern to search for (a basic regex unless fixed is true).",
22
+ required: true
23
+ param :path, type: "string",
24
+ desc: "Restrict the search to this file or directory, relative to fs_root. Optional.",
25
+ required: false
26
+ param :ignore_case, type: "boolean",
27
+ desc: "Case-insensitive search. Default false.",
28
+ required: false
29
+ param :fixed, type: "boolean",
30
+ desc: "Treat the pattern as a literal string rather than a regex. Default false.",
31
+ required: false
32
+
33
+ def execute(pattern:, path: nil, ignore_case: false, fixed: false)
34
+ pat = pattern.to_s
35
+ return error("pattern must not be empty", code: :empty_pattern) if pat.empty?
36
+
37
+ rel = repo_relative(path)
38
+ args = ["grep", "-n", "-I", "--no-color"]
39
+ args << "-i" if ignore_case
40
+ args << "-F" if fixed
41
+ args += ["-e", pat] # -e guards against a pattern that begins with "-"
42
+ args += ["--", rel] if rel
43
+
44
+ out, err, status = run_git(*args)
45
+ interpret(out, err, status)
46
+ rescue Safety::PathJail::Jailbreak => e
47
+ error(e.message, code: :path_denied)
48
+ end
49
+
50
+ private
51
+
52
+ # git grep exits 1 (with no error output) when there are simply no
53
+ # matches — that's a normal result, not a tool failure.
54
+ def interpret(out, err, status)
55
+ return error("timed out after #{config.command_timeout}s", code: :timeout) if status == :timeout
56
+
57
+ case status.exitstatus
58
+ when 0 then truncate(out)
59
+ when 1 then err.to_s.strip.empty? ? "no matches" : error(err.strip, code: :git_error)
60
+ else error(err.to_s.strip.empty? ? out.to_s.strip : err.strip, code: :git_error)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "ruby_llm/toolbox/process_runner"
5
+ require "ruby_llm/toolbox/safety/path_jail"
6
+
7
+ module RubyLLM
8
+ module Toolbox
9
+ module Tools
10
+ # Shared behavior for the git tools. Mixed into each so they all run git
11
+ # the same hardened way.
12
+ #
13
+ # Hardening matters here because git executes commands configured *by the
14
+ # repository* in several places, which turns "read-only" commands into RCE
15
+ # when operating on an untrusted checkout:
16
+ # - core.fsmonitor runs a command during `git status`
17
+ # - diff.external / textconv run commands during `git diff`
18
+ # The read tools are on by default, so the runner neutralizes those
19
+ # (`-c core.fsmonitor=`, and diff adds --no-ext-diff --no-textconv). The
20
+ # pager and credential prompts are also disabled so nothing can hang.
21
+ module GitHelpers
22
+ GIT_ENV = { "GIT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
23
+ # A ref/branch the model supplies: no leading dash (option injection),
24
+ # and only the characters git refs legitimately use.
25
+ REF_RE = %r{\A[A-Za-z0-9][\w./\-]*\z}
26
+
27
+ def run_git(*args, stdin: nil)
28
+ argv = ["git", "-c", "core.fsmonitor=", "-C", config.fs_root, "--no-pager", *args]
29
+ ProcessRunner.capture(argv, env: git_env, stdin: stdin, timeout: config.command_timeout, unsetenv_others: true)
30
+ end
31
+
32
+ # Maps a finished git run onto the toolbox return contract.
33
+ def git_result(out, err, status)
34
+ return error("git timed out after #{config.command_timeout}s", code: :timeout) if status == :timeout
35
+ return out if status.exitstatus&.zero?
36
+
37
+ message = (err.empty? ? out : err).strip
38
+ code = message.match?(/not a git repository/i) ? :not_a_repo : :git_error
39
+ error("git failed: #{message}", code: code)
40
+ end
41
+
42
+ def valid_ref?(ref)
43
+ ref.to_s.match?(REF_RE)
44
+ end
45
+
46
+ # Resolve a path param to a repo-relative path, rejecting jail escapes.
47
+ # Returns nil for a blank path. Raises PathJail::Jailbreak on escape.
48
+ def repo_relative(path)
49
+ return nil if path.nil? || path.to_s.empty?
50
+
51
+ jail = Safety::PathJail.new(config.fs_root)
52
+ real = jail.resolve(path)
53
+ Pathname.new(real).relative_path_from(Pathname.new(jail.root)).to_s
54
+ end
55
+
56
+ private
57
+
58
+ def git_env
59
+ env = config.env_passthrough.each_with_object({}) do |key, acc|
60
+ value = ENV[key]
61
+ acc[key] = value unless value.nil?
62
+ end
63
+ env.merge(GIT_ENV)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,47 @@
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
+ # SAFE. Shows recent commit history for the repo at fs_root. Read-only.
10
+ class GitLog < Base
11
+ include GitHelpers
12
+
13
+ description "Show recent git commit history for the repository at fs_root (hash, date, " \
14
+ "author, subject). Optionally limit the count or scope to a path. Read-only."
15
+
16
+ DEFAULT_COUNT = 20
17
+ MAX_COUNT = 200
18
+ FORMAT = "%h %ad %an: %s"
19
+
20
+ param :count, type: "integer",
21
+ desc: "Number of commits to show (default 20, max 200).",
22
+ required: false
23
+ param :path, type: "string",
24
+ desc: "Limit history to commits touching this path, relative to fs_root. Optional.",
25
+ required: false
26
+
27
+ def execute(count: DEFAULT_COUNT, path: nil)
28
+ n = count.to_i
29
+ n = DEFAULT_COUNT if n <= 0
30
+ n = MAX_COUNT if n > MAX_COUNT
31
+
32
+ rel = repo_relative(path)
33
+ args = ["log", "--max-count=#{n}", "--date=short", "--pretty=format:#{FORMAT}"]
34
+ args += ["--", rel] if rel
35
+
36
+ out, err, status = run_git(*args)
37
+ result = git_result(out, err, status)
38
+ return result if result.is_a?(Hash)
39
+
40
+ truncate(result.strip.empty? ? "no commits" : result)
41
+ rescue Safety::PathJail::Jailbreak => e
42
+ error(e.message, code: :path_denied)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,48 @@
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
+ # SAFE. Shows a commit (message + diff) or the contents of a file at a
10
+ # given ref. Read-only. External diff drivers and textconv are disabled.
11
+ class GitShow < Base
12
+ include GitHelpers
13
+
14
+ description "Show a git object in the repo at fs_root. With no path, shows the commit at ref " \
15
+ "(message and diff). With a path, shows that file's contents as of ref. Defaults " \
16
+ "to HEAD. Read-only."
17
+
18
+ param :ref, type: "string",
19
+ desc: "Commit/branch/tag to show. Default HEAD.",
20
+ required: false
21
+ param :path, type: "string",
22
+ desc: "If given, show this file's contents at ref instead of the commit. Optional.",
23
+ required: false
24
+
25
+ def execute(ref: "HEAD", path: nil)
26
+ ref = ref.to_s.strip
27
+ ref = "HEAD" if ref.empty?
28
+ return error("invalid ref: #{ref.inspect}", code: :bad_ref) unless valid_ref?(ref)
29
+
30
+ rel = repo_relative(path)
31
+ args = if rel
32
+ ["show", "#{ref}:#{rel}"]
33
+ else
34
+ ["show", "--no-ext-diff", "--no-textconv", "--stat", "--patch", ref]
35
+ end
36
+
37
+ out, err, status = run_git(*args)
38
+ result = git_result(out, err, status)
39
+ return result if result.is_a?(Hash)
40
+
41
+ truncate(result)
42
+ rescue Safety::PathJail::Jailbreak => e
43
+ error(e.message, code: :path_denied)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,27 @@
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
+ # SAFE. Shows the working-tree status of the repo at fs_root (branch plus
10
+ # staged/unstaged/untracked changes). Read-only; requires git on the host.
11
+ class GitStatus < Base
12
+ include GitHelpers
13
+
14
+ description "Show the git working-tree status of the repository at fs_root: current branch " \
15
+ "and staged, unstaged, and untracked changes. Read-only."
16
+
17
+ def execute
18
+ out, err, status = run_git("status", "--short", "--branch")
19
+ result = git_result(out, err, status)
20
+ return result if result.is_a?(Hash)
21
+
22
+ truncate(result.strip.empty? ? "working tree clean" : result)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,62 @@
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. Finds files matching a glob pattern within fs_root and returns
11
+ # their paths relative to the glob base. Each hit is re-checked through
12
+ # the path jail so a symlinked match that points outside the root is
13
+ # dropped.
14
+ class Glob < Base
15
+ description "Find files matching a glob pattern within fs_root (e.g. '**/*.rb', " \
16
+ "'app/models/*.rb'). Returns matching paths, sorted. Results are capped " \
17
+ "and token-budgeted."
18
+
19
+ param :pattern, type: "string",
20
+ desc: "Glob pattern such as '**/*.rb', evaluated relative to base.",
21
+ required: true
22
+ param :base, type: "string",
23
+ desc: "Directory to glob from, relative to fs_root. Defaults to fs_root.",
24
+ required: false
25
+
26
+ MAX_RESULTS = 1_000
27
+
28
+ def execute(pattern:, base: ".")
29
+ return error("pattern must be provided", code: :bad_pattern) if pattern.to_s.empty?
30
+ return error("pattern may not contain '..'", code: :bad_pattern) if pattern.include?("..")
31
+
32
+ jail = Safety::PathJail.new(config.fs_root)
33
+ root = jail.resolve(base)
34
+ return error("not a directory: #{base}", code: :not_a_directory) unless File.directory?(root)
35
+
36
+ base_pn = Pathname.new(root)
37
+ results = Dir.glob(File.join(root, pattern), File::FNM_PATHNAME).sort.filter_map do |match|
38
+ in_jail?(jail, match) ? Pathname.new(match).relative_path_from(base_pn).to_s : nil
39
+ end
40
+
41
+ capped = results.first(MAX_RESULTS)
42
+ body = +"#{results.size} match#{results.size == 1 ? '' : 'es'}"
43
+ body << " (showing first #{MAX_RESULTS})" if results.size > MAX_RESULTS
44
+ body << " for #{pattern}:\n"
45
+ capped.each { |rel| body << rel << "\n" }
46
+ truncate(body)
47
+ rescue Safety::PathJail::Jailbreak => e
48
+ error(e.message, code: :path_denied)
49
+ end
50
+
51
+ private
52
+
53
+ def in_jail?(jail, match)
54
+ jail.resolve(match)
55
+ File.exist?(match)
56
+ rescue Safety::PathJail::Jailbreak
57
+ false
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end