shared_tools 0.4.4 → 0.7.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +211 -7
  4. data/lib/shared_tools/apply_patch_tool.rb +4 -0
  5. data/lib/shared_tools/bash_tool.rb +4 -0
  6. data/lib/shared_tools/bundle_tool.rb +4 -0
  7. data/lib/shared_tools/command_guard.rb +35 -0
  8. data/lib/shared_tools/csv_read_tool.rb +4 -0
  9. data/lib/shared_tools/csv_write_tool.rb +4 -0
  10. data/lib/shared_tools/data_path.rb +52 -0
  11. data/lib/shared_tools/diff_tool.rb +4 -0
  12. data/lib/shared_tools/download_file_tool.rb +4 -0
  13. data/lib/shared_tools/gem_tool.rb +4 -0
  14. data/lib/shared_tools/git_tool.rb +5 -0
  15. data/lib/shared_tools/glob_tool.rb +4 -0
  16. data/lib/shared_tools/http_request_tool.rb +4 -0
  17. data/lib/shared_tools/json_query_tool.rb +4 -0
  18. data/lib/shared_tools/lint_tool.rb +4 -0
  19. data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
  20. data/lib/shared_tools/mcp/chart_client.rb +18 -15
  21. data/lib/shared_tools/mcp/client_support.rb +103 -0
  22. data/lib/shared_tools/mcp/github_client.rb +23 -19
  23. data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
  24. data/lib/shared_tools/mcp/memory_client.rb +20 -15
  25. data/lib/shared_tools/mcp/notion_client.rb +21 -19
  26. data/lib/shared_tools/mcp/playwright_client.rb +19 -15
  27. data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
  28. data/lib/shared_tools/mcp/slack_client.rb +30 -25
  29. data/lib/shared_tools/mcp/tavily_client.rb +21 -21
  30. data/lib/shared_tools/mcp.rb +32 -41
  31. data/lib/shared_tools/multi_edit_tool.rb +4 -0
  32. data/lib/shared_tools/parse_ruby_tool.rb +4 -0
  33. data/lib/shared_tools/process_kill_tool.rb +4 -0
  34. data/lib/shared_tools/process_list_tool.rb +4 -0
  35. data/lib/shared_tools/process_output_tool.rb +4 -0
  36. data/lib/shared_tools/process_registry.rb +224 -0
  37. data/lib/shared_tools/process_runner.rb +69 -0
  38. data/lib/shared_tools/process_start_tool.rb +4 -0
  39. data/lib/shared_tools/python_tests_tool.rb +4 -0
  40. data/lib/shared_tools/replace_in_files_tool.rb +4 -0
  41. data/lib/shared_tools/ruby_outline.rb +207 -0
  42. data/lib/shared_tools/run_tests_tool.rb +4 -0
  43. data/lib/shared_tools/text_diff.rb +80 -0
  44. data/lib/shared_tools/todo_write_tool.rb +4 -0
  45. data/lib/shared_tools/toml_parser.rb +409 -0
  46. data/lib/shared_tools/toml_query_tool.rb +4 -0
  47. data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
  48. data/lib/shared_tools/tools/bash_tool.rb +102 -0
  49. data/lib/shared_tools/tools/browser/click_tool.rb +2 -2
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +2 -2
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +2 -2
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +2 -2
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +2 -2
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +2 -2
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +2 -2
  56. data/lib/shared_tools/tools/browser_tool.rb +2 -2
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +2 -2
  59. data/lib/shared_tools/tools/clipboard_tool.rb +2 -2
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +2 -2
  61. data/lib/shared_tools/tools/computer_tool.rb +2 -2
  62. data/lib/shared_tools/tools/cron_tool.rb +2 -2
  63. data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
  64. data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
  65. data/lib/shared_tools/tools/current_date_time_tool.rb +3 -3
  66. data/lib/shared_tools/tools/data_science_kit.rb +2 -2
  67. data/lib/shared_tools/tools/database_query_tool.rb +2 -2
  68. data/lib/shared_tools/tools/database_tool.rb +2 -2
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +2 -2
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +2 -2
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +2 -2
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +2 -2
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +2 -2
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +2 -2
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +2 -2
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +2 -2
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +2 -2
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +2 -2
  80. data/lib/shared_tools/tools/disk_tool.rb +2 -2
  81. data/lib/shared_tools/tools/dns_tool.rb +2 -2
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +2 -2
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +2 -2
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +2 -2
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +2 -2
  86. data/lib/shared_tools/tools/doc_tool.rb +2 -2
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +2 -2
  88. data/lib/shared_tools/tools/download_file_tool.rb +87 -0
  89. data/lib/shared_tools/tools/enabler.rb +2 -2
  90. data/lib/shared_tools/tools/error_handling_tool.rb +2 -2
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +2 -2
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +2 -2
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +2 -2
  94. data/lib/shared_tools/tools/eval_tool.rb +2 -2
  95. data/lib/shared_tools/tools/gem_tool.rb +157 -0
  96. data/lib/shared_tools/tools/git/add_tool.rb +73 -0
  97. data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
  98. data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
  99. data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
  100. data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
  101. data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
  102. data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
  103. data/lib/shared_tools/tools/git/helpers.rb +70 -0
  104. data/lib/shared_tools/tools/git/log_tool.rb +71 -0
  105. data/lib/shared_tools/tools/git/show_tool.rb +66 -0
  106. data/lib/shared_tools/tools/git/status_tool.rb +46 -0
  107. data/lib/shared_tools/tools/git.rb +18 -0
  108. data/lib/shared_tools/tools/git_tool.rb +148 -0
  109. data/lib/shared_tools/tools/glob_tool.rb +75 -0
  110. data/lib/shared_tools/tools/http_helpers.rb +119 -0
  111. data/lib/shared_tools/tools/http_request_tool.rb +91 -0
  112. data/lib/shared_tools/tools/json_query_tool.rb +81 -0
  113. data/lib/shared_tools/tools/lint_tool.rb +93 -0
  114. data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
  115. data/lib/shared_tools/tools/notification_tool.rb +2 -2
  116. data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
  117. data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
  118. data/lib/shared_tools/tools/process_list_tool.rb +41 -0
  119. data/lib/shared_tools/tools/process_output_tool.rb +69 -0
  120. data/lib/shared_tools/tools/process_start_tool.rb +0 -0
  121. data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
  122. data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
  123. data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
  124. data/lib/shared_tools/tools/search_codebase_tool.rb +182 -51
  125. data/lib/shared_tools/tools/secure_tool_template.rb +2 -2
  126. data/lib/shared_tools/tools/system_info_tool.rb +2 -2
  127. data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
  128. data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
  129. data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
  130. data/lib/shared_tools/tools/tree_tool.rb +100 -0
  131. data/lib/shared_tools/tools/weather_tool.rb +2 -2
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +2 -2
  134. data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
  135. data/lib/shared_tools/tree_tool.rb +4 -0
  136. data/lib/shared_tools/url_guard.rb +106 -0
  137. data/lib/shared_tools/utilities.rb +2 -2
  138. data/lib/shared_tools/version.rb +1 -1
  139. data/lib/shared_tools/web_fetch_tool.rb +4 -0
  140. data/lib/shared_tools/yaml_query_tool.rb +4 -0
  141. data/lib/shared_tools.rb +1 -1
  142. metadata +532 -409
  143. data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
  144. data/lib/shared_tools/tools/version.rb +0 -7
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ module Git
9
+ # Shared behavior for the git tools: running git the same hardened way,
10
+ # and mapping its output onto the shared_tools { error: } contract.
11
+ #
12
+ # Hardening matters because git executes commands configured *by the
13
+ # repository* in several places, which turns "read-only" commands into
14
+ # arbitrary code execution when operating on an untrusted checkout:
15
+ # - core.fsmonitor runs a command during `git status`
16
+ # - diff.external / textconv run commands during `git diff`
17
+ # core.fsmonitor is disabled via `-c`, and the diff/show tools pass
18
+ # --no-ext-diff --no-textconv. The pager and credential prompts are
19
+ # also disabled so a call can never hang waiting on a TTY.
20
+ module Helpers
21
+ GIT_ENV = { "GIT_PAGER" => "cat", "GIT_TERMINAL_PROMPT" => "0" }.freeze
22
+ # A ref/branch the model supplies: no leading dash (option injection),
23
+ # and only the characters git refs legitimately use.
24
+ REF_RE = %r{\A[A-Za-z0-9][\w./\-]*\z}
25
+
26
+ # @param args [Array<String>]
27
+ # @param repo_root [String]
28
+ # @return [Array(String, String, Process::Status)] stdout, stderr, status
29
+ def run_git(*args, repo_root:)
30
+ Open3.capture3(GIT_ENV, "git", "-c", "core.fsmonitor=", "-C", repo_root.to_s, "--no-pager", *args)
31
+ end
32
+
33
+ # Maps a finished git run onto the shared_tools return contract.
34
+ #
35
+ # @return [String, Hash] stdout on success, or an { error:, code: } hash
36
+ def git_result(out, err, status)
37
+ return out if status.success?
38
+
39
+ message = (err.to_s.empty? ? out.to_s : err.to_s).strip
40
+ code = message.match?(/not a git repository/i) ? :not_a_repo : :git_error
41
+ { error: message.empty? ? "git failed with exit code #{status.exitstatus}" : "git failed: #{message}", code: code }
42
+ end
43
+
44
+ # @param ref [String]
45
+ # @return [Boolean]
46
+ def valid_ref?(ref)
47
+ ref.to_s.match?(REF_RE)
48
+ end
49
+
50
+ # Resolve a path param to a repo-relative path, rejecting jail escapes.
51
+ # Returns nil for a blank path.
52
+ #
53
+ # @param path [String, nil]
54
+ # @param repo_root [String]
55
+ #
56
+ # @raise [SecurityError] if the path escapes repo_root
57
+ # @return [String, nil]
58
+ def repo_relative(path, repo_root:)
59
+ return nil if path.nil? || path.to_s.strip.empty?
60
+
61
+ root = Pathname.new(File.expand_path(repo_root))
62
+ resolved = (root + path).cleanpath
63
+ raise SecurityError, "path escapes repo_root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
64
+
65
+ resolved.relative_path_from(root).to_s
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows recent commit history for the repo at repo_root.
9
+ #
10
+ # @example
11
+ # tool = SharedTools::Tools::Git::LogTool.new
12
+ # tool.execute(count: 10)
13
+ class LogTool < ::RubyLLM::Tool
14
+ include Helpers
15
+
16
+ DEFAULT_COUNT = 20
17
+ MAX_COUNT = 200
18
+ FORMAT = "%h %ad %an: %s"
19
+
20
+ def self.tool_name = 'git_log'
21
+
22
+ description "Show recent git commit history for the repository (hash, date, author, " \
23
+ "subject). Optionally limit the count or scope to a path. Read-only."
24
+
25
+ parameters do
26
+ integer :count, description: "Number of commits to show (default #{DEFAULT_COUNT}, max #{MAX_COUNT}).", required: false
27
+ string :path, description: "Limit history to commits touching this path, relative to the repo root. Optional.", required: false
28
+ end
29
+
30
+ # @param repo_root [String] optional, defaults to the current directory
31
+ # @param logger [Logger] optional logger
32
+ def initialize(repo_root: nil, logger: nil)
33
+ @repo_root = repo_root || Dir.pwd
34
+ @logger = logger || RubyLLM.logger
35
+ end
36
+
37
+ # @param count [Integer]
38
+ # @param path [String, nil]
39
+ #
40
+ # @return [String, Hash]
41
+ def execute(count: DEFAULT_COUNT, path: nil)
42
+ @logger.info("#{self.class.name}#execute count=#{count} path=#{path.inspect}")
43
+
44
+ n = count.to_i
45
+ n = DEFAULT_COUNT if n <= 0
46
+ n = MAX_COUNT if n > MAX_COUNT
47
+
48
+ rel = repo_relative(path, repo_root: @repo_root)
49
+ args = ["log", "--max-count=#{n}", "--date=short", "--pretty=format:#{FORMAT}"]
50
+ args += ["--", rel] if rel
51
+
52
+ out, err, status = run_git(*args, repo_root: @repo_root)
53
+ result = git_result(out, err, status)
54
+ if result.is_a?(Hash)
55
+ return "no commits" if result[:error].to_s.match?(/does not have any commits yet|bad default revision/i)
56
+
57
+ return result
58
+ end
59
+
60
+ result.strip.empty? ? "no commits" : result
61
+ rescue SecurityError => e
62
+ @logger.error("#{self.class.name} path denied: #{e.message}")
63
+ { error: e.message }
64
+ rescue => e
65
+ @logger.error("#{self.class.name} failed: #{e.message}")
66
+ { error: e.message }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows a commit (message + diff) or the contents of a file
9
+ # at a given ref. External diff drivers and textconv are disabled.
10
+ #
11
+ # @example
12
+ # tool = SharedTools::Tools::Git::ShowTool.new
13
+ # tool.execute
14
+ # tool.execute(ref: "HEAD~1", path: "lib/foo.rb")
15
+ class ShowTool < ::RubyLLM::Tool
16
+ include Helpers
17
+
18
+ def self.tool_name = 'git_show'
19
+
20
+ description "Show a git object in the repository. With no path, shows the commit at ref " \
21
+ "(message and diff). With a path, shows that file's contents as of ref. Defaults " \
22
+ "to HEAD. Read-only."
23
+
24
+ parameters do
25
+ string :ref, description: "Commit/branch/tag to show. Default HEAD.", required: false
26
+ string :path, description: "If given, show this file's contents at ref instead of the commit. Optional.", required: false
27
+ end
28
+
29
+ # @param repo_root [String] optional, defaults to the current directory
30
+ # @param logger [Logger] optional logger
31
+ def initialize(repo_root: nil, logger: nil)
32
+ @repo_root = repo_root || Dir.pwd
33
+ @logger = logger || RubyLLM.logger
34
+ end
35
+
36
+ # @param ref [String]
37
+ # @param path [String, nil]
38
+ #
39
+ # @return [String, Hash]
40
+ def execute(ref: "HEAD", path: nil)
41
+ @logger.info("#{self.class.name}#execute ref=#{ref.inspect} path=#{path.inspect}")
42
+
43
+ ref = ref.to_s.strip
44
+ ref = "HEAD" if ref.empty?
45
+ return { error: "invalid ref: #{ref.inspect}" } unless valid_ref?(ref)
46
+
47
+ rel = repo_relative(path, repo_root: @repo_root)
48
+ args = if rel
49
+ ["show", "#{ref}:#{rel}"]
50
+ else
51
+ ["show", "--no-ext-diff", "--no-textconv", "--stat", "--patch", ref]
52
+ end
53
+
54
+ out, err, status = run_git(*args, repo_root: @repo_root)
55
+ git_result(out, err, status)
56
+ rescue SecurityError => e
57
+ @logger.error("#{self.class.name} path denied: #{e.message}")
58
+ { error: e.message }
59
+ rescue => e
60
+ @logger.error("#{self.class.name} failed: #{e.message}")
61
+ { error: e.message }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "helpers"
4
+
5
+ module SharedTools
6
+ module Tools
7
+ module Git
8
+ # Read-only. Shows the working-tree status of the repo at repo_root
9
+ # (current branch plus staged/unstaged/untracked changes).
10
+ #
11
+ # @example
12
+ # tool = SharedTools::Tools::Git::StatusTool.new
13
+ # tool.execute
14
+ class StatusTool < ::RubyLLM::Tool
15
+ include Helpers
16
+
17
+ def self.tool_name = 'git_status'
18
+
19
+ description "Show the git working-tree status of the repository: current branch and " \
20
+ "staged, unstaged, and untracked changes. Read-only."
21
+
22
+ # @param repo_root [String] optional, defaults to the current directory
23
+ # @param logger [Logger] optional logger
24
+ def initialize(repo_root: nil, logger: nil)
25
+ @repo_root = repo_root || Dir.pwd
26
+ @logger = logger || RubyLLM.logger
27
+ end
28
+
29
+ # @return [String, Hash]
30
+ def execute
31
+ @logger.info("#{self.class.name}#execute repo_root=#{@repo_root}")
32
+
33
+ out, err, status = run_git("status", "--short", "--branch", repo_root: @repo_root)
34
+ result = git_result(out, err, status)
35
+ return result if result.is_a?(Hash)
36
+
37
+ changes = result.each_line.reject { |line| line.start_with?("##") }
38
+ changes.empty? ? "working tree clean" : result
39
+ rescue => e
40
+ @logger.error("#{self.class.name} failed: #{e.message}")
41
+ { error: e.message }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collection loader for all git tools
4
+ # Usage: require 'shared_tools/tools/git'
5
+
6
+ require 'shared_tools'
7
+
8
+ require_relative 'git/helpers'
9
+ require_relative 'git/status_tool'
10
+ require_relative 'git/diff_tool'
11
+ require_relative 'git/log_tool'
12
+ require_relative 'git/show_tool'
13
+ require_relative 'git/blame_tool'
14
+ require_relative 'git/branch_tool'
15
+ require_relative 'git/grep_tool'
16
+ require_relative 'git/add_tool'
17
+ require_relative 'git/commit_tool'
18
+ require_relative 'git/checkout_tool'
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ # A tool for interacting with a git repository: status, diff, log, show,
8
+ # blame, branch, grep, add, commit, and checkout. Mutating actions (add,
9
+ # commit, checkout) require user authorization (see SharedTools.execute?).
10
+ #
11
+ # @example
12
+ # git = SharedTools::Tools::GitTool.new
13
+ # git.execute(action: SharedTools::Tools::GitTool::Action::STATUS)
14
+ # git.execute(action: SharedTools::Tools::GitTool::Action::LOG, count: 5)
15
+ # git.execute(action: SharedTools::Tools::GitTool::Action::COMMIT, message: "Fix the thing")
16
+ class GitTool < ::RubyLLM::Tool
17
+ def self.tool_name = 'git'
18
+
19
+ description <<~TEXT
20
+ A tool for interacting with a git repository. Read-only actions (status, diff, log, show,
21
+ blame, branch, grep) require no authorization. Mutating actions (add, commit, checkout)
22
+ require user authorization.
23
+ TEXT
24
+
25
+ module Action
26
+ STATUS = "status"
27
+ DIFF = "diff"
28
+ LOG = "log"
29
+ SHOW = "show"
30
+ BLAME = "blame"
31
+ BRANCH = "branch"
32
+ GREP = "grep"
33
+ ADD = "add"
34
+ COMMIT = "commit"
35
+ CHECKOUT = "checkout"
36
+ end
37
+
38
+ ACTIONS = [
39
+ Action::STATUS,
40
+ Action::DIFF,
41
+ Action::LOG,
42
+ Action::SHOW,
43
+ Action::BLAME,
44
+ Action::BRANCH,
45
+ Action::GREP,
46
+ Action::ADD,
47
+ Action::COMMIT,
48
+ Action::CHECKOUT,
49
+ ].freeze
50
+
51
+ parameters do
52
+ string :action, description: "The git operation to perform. One of: #{ACTIONS.join(', ')}"
53
+ boolean :staged, description: "For `#{Action::DIFF}`: show staged changes instead of unstaged.", required: false
54
+ string :path, description: "A file or directory path, relative to the repo root. Used by #{Action::DIFF}, #{Action::LOG}, #{Action::SHOW}, #{Action::BLAME}, #{Action::GREP}.", required: false
55
+ string :ref, description: "A commit/branch/tag. Used by #{Action::DIFF}, #{Action::SHOW}, #{Action::CHECKOUT}.", required: false
56
+ integer :count, description: "For `#{Action::LOG}`: number of commits to show.", required: false
57
+ integer :start_line, description: "For `#{Action::BLAME}`: first line of the range.", required: false
58
+ integer :end_line, description: "For `#{Action::BLAME}`: last line of the range.", required: false
59
+ boolean :all, description: "For `#{Action::BRANCH}`: include remote-tracking branches. For `#{Action::ADD}`: stage all changes. For `#{Action::COMMIT}`: stage tracked files first.", required: false
60
+ string :pattern, description: "For `#{Action::GREP}`: the pattern to search for.", required: false
61
+ boolean :ignore_case, description: "For `#{Action::GREP}`: case-insensitive search.", required: false
62
+ boolean :fixed, description: "For `#{Action::GREP}`: treat pattern as a literal string.", required: false
63
+ array :paths, of: :string, description: "For `#{Action::ADD}`: paths to stage.", required: false
64
+ string :message, description: "For `#{Action::COMMIT}`: the commit message.", required: false
65
+ boolean :create, description: "For `#{Action::CHECKOUT}`: create ref as a new branch.", required: false
66
+ end
67
+
68
+ # @param repo_root [String] optional, defaults to the current directory
69
+ # @param logger [Logger] optional logger
70
+ def initialize(repo_root: nil, logger: nil)
71
+ @repo_root = repo_root || Dir.pwd
72
+ @logger = logger || RubyLLM.logger
73
+ end
74
+
75
+ # @return [String, Hash]
76
+ def execute(action:, **opts)
77
+ @logger.info("GitTool#execute action=#{action}")
78
+
79
+ case action.to_s.downcase
80
+ when Action::STATUS then status_tool.execute
81
+ when Action::DIFF then diff_tool.execute(staged: opts[:staged] || false, path: opts[:path], ref: opts[:ref])
82
+ when Action::LOG then log_tool.execute(count: opts[:count] || Git::LogTool::DEFAULT_COUNT, path: opts[:path])
83
+ when Action::SHOW then show_tool.execute(ref: opts[:ref] || "HEAD", path: opts[:path])
84
+ when Action::BLAME then require_param!(:path, opts[:path]) && blame_tool.execute(path: opts[:path], start_line: opts[:start_line], end_line: opts[:end_line])
85
+ when Action::BRANCH then branch_tool.execute(all: opts[:all] || false)
86
+ when Action::GREP then require_param!(:pattern, opts[:pattern]) && grep_tool.execute(pattern: opts[:pattern], path: opts[:path], ignore_case: opts[:ignore_case] || false, fixed: opts[:fixed] || false)
87
+ when Action::ADD then add_tool.execute(paths: opts[:paths], all: opts[:all] || false)
88
+ when Action::COMMIT then require_param!(:message, opts[:message]) && commit_tool.execute(message: opts[:message], all: opts[:all] || false)
89
+ when Action::CHECKOUT then require_param!(:ref, opts[:ref]) && checkout_tool.execute(ref: opts[:ref], create: opts[:create] || false)
90
+ else
91
+ { error: "Unsupported action: #{action}. Supported actions are: #{ACTIONS.join(', ')}" }
92
+ end
93
+ rescue StandardError => e
94
+ @logger.error("GitTool execution failed: #{e.message}")
95
+ { error: e.message }
96
+ end
97
+
98
+ private
99
+
100
+ # @raise [ArgumentError]
101
+ def require_param!(name, value)
102
+ raise ArgumentError, "#{name} param is required for this action" if value.nil?
103
+
104
+ true
105
+ end
106
+
107
+ def status_tool
108
+ @status_tool ||= Git::StatusTool.new(repo_root: @repo_root, logger: @logger)
109
+ end
110
+
111
+ def diff_tool
112
+ @diff_tool ||= Git::DiffTool.new(repo_root: @repo_root, logger: @logger)
113
+ end
114
+
115
+ def log_tool
116
+ @log_tool ||= Git::LogTool.new(repo_root: @repo_root, logger: @logger)
117
+ end
118
+
119
+ def show_tool
120
+ @show_tool ||= Git::ShowTool.new(repo_root: @repo_root, logger: @logger)
121
+ end
122
+
123
+ def blame_tool
124
+ @blame_tool ||= Git::BlameTool.new(repo_root: @repo_root, logger: @logger)
125
+ end
126
+
127
+ def branch_tool
128
+ @branch_tool ||= Git::BranchTool.new(repo_root: @repo_root, logger: @logger)
129
+ end
130
+
131
+ def grep_tool
132
+ @grep_tool ||= Git::GrepTool.new(repo_root: @repo_root, logger: @logger)
133
+ end
134
+
135
+ def add_tool
136
+ @add_tool ||= Git::AddTool.new(repo_root: @repo_root, logger: @logger)
137
+ end
138
+
139
+ def commit_tool
140
+ @commit_tool ||= Git::CommitTool.new(repo_root: @repo_root, logger: @logger)
141
+ end
142
+
143
+ def checkout_tool
144
+ @checkout_tool ||= Git::CheckoutTool.new(repo_root: @repo_root, logger: @logger)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require_relative '../../shared_tools'
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Finds files matching a glob pattern within a directory and returns
9
+ # their paths relative to the glob base, sorted. Each hit is re-checked
10
+ # against the base so a symlinked match that points outside it is
11
+ # dropped. Read-only, in-process — no authorization prompt required.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::GlobTool.new
15
+ # tool.execute(pattern: "**/*.rb")
16
+ # tool.execute(pattern: "*.rb", base: "app/models")
17
+ class GlobTool < ::RubyLLM::Tool
18
+ MAX_RESULTS = 1_000
19
+
20
+ def self.tool_name = 'glob'
21
+
22
+ description "Find files matching a glob pattern within a directory (e.g. '**/*.rb', " \
23
+ "'app/models/*.rb'). Returns matching paths, sorted, relative to base."
24
+
25
+ parameters do
26
+ string :pattern, description: "Glob pattern such as '**/*.rb', evaluated relative to base."
27
+ string :base, description: "Directory to glob from. Defaults to the current directory.", required: false
28
+ end
29
+
30
+ # @param logger [Logger] optional logger
31
+ def initialize(logger: nil)
32
+ @logger = logger || RubyLLM.logger
33
+ end
34
+
35
+ # @param pattern [String]
36
+ # @param base [String]
37
+ #
38
+ # @return [String, Hash]
39
+ def execute(pattern:, base: ".")
40
+ @logger.info("#{self.class.name}#execute pattern=#{pattern.inspect} base=#{base.inspect}")
41
+
42
+ return { error: "pattern must be provided" } if pattern.to_s.empty?
43
+ return { error: "pattern may not contain '..'" } if pattern.include?("..")
44
+
45
+ return { error: "not a directory: #{base}" } unless File.directory?(File.expand_path(base))
46
+
47
+ root = File.realpath(File.expand_path(base))
48
+
49
+ base_pn = Pathname.new(root)
50
+ results = Dir.glob(File.join(root, pattern), File::FNM_PATHNAME).sort.filter_map do |match|
51
+ in_base?(root, match) ? Pathname.new(match).relative_path_from(base_pn).to_s : nil
52
+ end
53
+
54
+ capped = results.first(MAX_RESULTS)
55
+ body = +"#{results.size} match#{results.size == 1 ? '' : 'es'}"
56
+ body << " (showing first #{MAX_RESULTS})" if results.size > MAX_RESULTS
57
+ body << " for #{pattern}:\n"
58
+ capped.each { |rel| body << rel << "\n" }
59
+ body
60
+ rescue => e
61
+ @logger.error("#{self.class.name} failed: #{e.message}")
62
+ { error: e.message }
63
+ end
64
+
65
+ private
66
+
67
+ def in_base?(root, match)
68
+ real = File.realpath(match)
69
+ (real == root || real.start_with?(root + File::SEPARATOR)) && File.exist?(match)
70
+ rescue Errno::ENOENT
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require_relative "../../shared_tools"
6
+ require_relative "../url_guard"
7
+
8
+ module SharedTools
9
+ module Tools
10
+ # Shared guarded-HTTP behavior for WebFetchTool, HttpRequestTool, and
11
+ # DownloadFileTool. 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 to an internal address. SSRF protection is
14
+ # always on — there is no bypass.
15
+ module HttpHelpers
16
+ HTTP_TIMEOUT = 10
17
+ MAX_REDIRECTS = 5
18
+ MAX_FETCH_BYTES = 5 * 1024 * 1024
19
+ USER_AGENT = "SharedTools/#{SharedTools::VERSION} (+https://github.com/madbomber/shared_tools)"
20
+
21
+ Response = Struct.new(:status, :headers, :body, :final_url, keyword_init: true)
22
+
23
+ class FetchError < StandardError; end
24
+
25
+ def url_guard
26
+ SharedTools::UrlGuard.new(allowlist: @web_allowlist || [], denylist: @web_denylist || [])
27
+ end
28
+
29
+ # GET with redirect following and a body-size cap. Each hop is
30
+ # re-guarded and re-pinned.
31
+ def guarded_get(url)
32
+ checker = url_guard
33
+ current = url
34
+ seen = 0
35
+
36
+ loop do
37
+ resolution = checker.resolve!(current)
38
+ response, body = perform(resolution.uri, "GET", nil, nil, resolution.address)
39
+
40
+ if redirect?(response)
41
+ location = response["location"]
42
+ raise FetchError, "redirect with no Location header (status #{response.code})" unless location
43
+ raise FetchError, "too many redirects (max #{MAX_REDIRECTS})" if seen >= MAX_REDIRECTS
44
+
45
+ seen += 1
46
+ current = URI.join(resolution.uri.to_s, location).to_s
47
+ next
48
+ end
49
+
50
+ return Response.new(status: response.code.to_i, headers: response.to_hash,
51
+ body: body, final_url: resolution.uri.to_s)
52
+ end
53
+ rescue SharedTools::UrlGuard::Blocked
54
+ raise
55
+ rescue StandardError => e
56
+ raise FetchError, e.message
57
+ end
58
+
59
+ # A single request with an explicit method/headers/body (no redirect
60
+ # following). Used by HttpRequestTool.
61
+ def guarded_request(method, url, headers: {}, body: nil)
62
+ resolution = url_guard.resolve!(url)
63
+ response, raw = perform(resolution.uri, method.to_s.upcase, headers, body, resolution.address)
64
+ Response.new(status: response.code.to_i, headers: response.to_hash, body: raw, final_url: resolution.uri.to_s)
65
+ rescue SharedTools::UrlGuard::Blocked
66
+ raise
67
+ rescue StandardError => e
68
+ raise FetchError, e.message
69
+ end
70
+
71
+ private
72
+
73
+ def redirect?(response)
74
+ response.is_a?(Net::HTTPRedirection)
75
+ end
76
+
77
+ def perform(uri, method, headers, body, pin)
78
+ request = build_request(uri, method, headers, body)
79
+
80
+ http = Net::HTTP.new(uri.host, uri.port)
81
+ http.use_ssl = uri.scheme == "https"
82
+ http.ipaddr = pin if pin # pin to the vetted address (closes DNS rebinding)
83
+ http.open_timeout = HTTP_TIMEOUT
84
+ http.read_timeout = HTTP_TIMEOUT
85
+
86
+ captured = +""
87
+ response = http.start do |conn|
88
+ conn.request(request) do |res|
89
+ unless res.is_a?(Net::HTTPRedirection)
90
+ res.read_body do |chunk|
91
+ captured << chunk
92
+ break if captured.bytesize >= MAX_FETCH_BYTES
93
+ end
94
+ end
95
+ res
96
+ end
97
+ end
98
+
99
+ [response, captured]
100
+ end
101
+
102
+ def build_request(uri, method, headers, body)
103
+ klass = begin
104
+ Net::HTTP.const_get(method.capitalize)
105
+ rescue NameError
106
+ nil
107
+ end
108
+ raise FetchError, "unsupported HTTP method: #{method}" unless klass && klass <= Net::HTTPRequest
109
+
110
+ request = klass.new(uri)
111
+ request["User-Agent"] = USER_AGENT
112
+ request["Accept"] ||= "*/*"
113
+ Array(headers).each { |k, v| request[k.to_s] = v.to_s } if headers
114
+ request.body = body.to_s if body && !body.to_s.empty?
115
+ request
116
+ end
117
+ end
118
+ end
119
+ end