shared_tools 0.4.2 → 0.6.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 +52 -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 +1 -1
  50. data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
  51. data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
  52. data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
  53. data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
  54. data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
  55. data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
  56. data/lib/shared_tools/tools/browser_tool.rb +1 -1
  57. data/lib/shared_tools/tools/bundle_tool.rb +96 -0
  58. data/lib/shared_tools/tools/calculator_tool.rb +12 -2
  59. data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
  60. data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
  61. data/lib/shared_tools/tools/computer_tool.rb +1 -1
  62. data/lib/shared_tools/tools/cron_tool.rb +1 -1
  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 +1 -1
  66. data/lib/shared_tools/tools/data_science_kit.rb +1 -1
  67. data/lib/shared_tools/tools/database_query_tool.rb +12 -2
  68. data/lib/shared_tools/tools/database_tool.rb +1 -1
  69. data/lib/shared_tools/tools/diff_tool.rb +48 -0
  70. data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
  71. data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
  72. data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
  73. data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
  74. data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
  75. data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
  76. data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
  77. data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
  78. data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
  79. data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
  80. data/lib/shared_tools/tools/disk_tool.rb +1 -1
  81. data/lib/shared_tools/tools/dns_tool.rb +1 -1
  82. data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
  83. data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
  84. data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
  85. data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
  86. data/lib/shared_tools/tools/doc_tool.rb +1 -1
  87. data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
  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 +1 -1
  91. data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
  92. data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
  93. data/lib/shared_tools/tools/eval/shell_eval_tool.rb +27 -6
  94. data/lib/shared_tools/tools/eval_tool.rb +1 -1
  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 +1 -1
  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 +222 -0
  125. data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
  126. data/lib/shared_tools/tools/system_info_tool.rb +1 -1
  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 +12 -2
  132. data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
  133. data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
  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 +516 -394
  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,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "../../shared_tools"
5
+ require_relative "../process_runner"
6
+
7
+ module SharedTools
8
+ module Tools
9
+ # Shared behavior for the Ruby toolchain tools (BundleTool, LintTool,
10
+ # RunTestsTool, PythonTestsTool). Mixing tools must set @root in
11
+ # #initialize.
12
+ #
13
+ # Unlike BashTool, these run in @root and inherit the full host
14
+ # environment. That's deliberate: tools like bundler, rbenv/rvm, and the
15
+ # test/lint binaries rely on the Ruby environment (PATH shims, GEM_HOME,
16
+ # BUNDLE_*) to resolve correctly, and these are trusted dev commands the
17
+ # caller opted into (they all require SharedTools.execute? authorization).
18
+ module ToolchainHelpers
19
+ class CommandMissing < StandardError; end
20
+
21
+ def gemfile?
22
+ File.file?(File.join(@root, "Gemfile"))
23
+ end
24
+
25
+ # Runs argv in @root. With use_bundle, prefixes `bundle exec` when a
26
+ # Gemfile is present. Returns [out, err, status]; raises CommandMissing
27
+ # if the executable can't be found.
28
+ def run_in_project(argv, use_bundle: false, timeout: 120)
29
+ command = use_bundle && gemfile? ? ["bundle", "exec", *argv] : argv
30
+ SharedTools::ProcessRunner.capture(
31
+ command,
32
+ env: {}, # inherit the full host environment
33
+ chdir: @root,
34
+ timeout: timeout,
35
+ unsetenv_others: false
36
+ )
37
+ rescue Errno::ENOENT
38
+ raise CommandMissing, command.first
39
+ end
40
+
41
+ # Maps a finished toolchain run onto a return value. Non-zero exit is
42
+ # NOT treated as a tool error here — failing tests / lint offenses are
43
+ # results the caller needs to see, with a headline prepended.
44
+ def toolchain_output(out, err, status, pass_label:, fail_label:, timeout:)
45
+ return { error: "timed out after #{timeout}s" } if status == :timeout
46
+
47
+ combined = [out, err].map(&:to_s).reject(&:empty?).join("\n")
48
+ headline = status.exitstatus.zero? ? pass_label : fail_label
49
+ "#{headline}\n\n#{combined}".strip
50
+ end
51
+
52
+ # Resolve a path param to a root-relative path, rejecting escapes.
53
+ def jail_relative(path)
54
+ return nil if path.nil? || path.to_s.empty?
55
+
56
+ root = Pathname.new(File.expand_path(@root))
57
+ resolved = (root + path).cleanpath
58
+ raise SecurityError, "path escapes root: #{path}" unless resolved.ascend.any? { |ancestor| ancestor == root }
59
+
60
+ resolved.relative_path_from(root).to_s
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../shared_tools'
4
+
5
+ module SharedTools
6
+ module Tools
7
+ # Renders a depth-limited directory tree — a quick way to see project
8
+ # structure without walking it one level at a time with
9
+ # Disk::DirectoryListTool. Read-only; ignored directories are skipped,
10
+ # symlinks are not followed, and the listing is capped.
11
+ #
12
+ # @example
13
+ # tool = SharedTools::Tools::TreeTool.new
14
+ # tool.execute
15
+ # tool.execute(path: "lib", max_depth: 2)
16
+ class TreeTool < ::RubyLLM::Tool
17
+ DEFAULT_DEPTH = 3
18
+ MAX_ENTRIES = 500
19
+ IGNORED_DIRS = %w[.git .hg .svn node_modules .bundle tmp].freeze
20
+
21
+ def self.name = 'tree'
22
+
23
+ description "Show a directory tree, to a limited depth. Directories are marked with a " \
24
+ "trailing slash. Skips ignored directories (node_modules, .git, ...) and hidden " \
25
+ "entries unless show_hidden is set."
26
+
27
+ parameters do
28
+ integer :max_depth, description: "How many levels deep to descend (default #{DEFAULT_DEPTH}).", required: false
29
+ string :path, description: "Directory to start from. Defaults to the current directory.", required: false
30
+ boolean :show_hidden, description: "Include dot-files and dot-directories. Default false.", required: false
31
+ end
32
+
33
+ # @param logger [Logger] optional logger
34
+ def initialize(logger: nil)
35
+ @logger = logger || RubyLLM.logger
36
+ end
37
+
38
+ # @param path [String, nil]
39
+ # @param max_depth [Integer]
40
+ # @param show_hidden [Boolean]
41
+ #
42
+ # @return [String, Hash]
43
+ def execute(path: nil, max_depth: DEFAULT_DEPTH, show_hidden: false)
44
+ @logger.info("#{self.class.name}#execute path=#{path.inspect} max_depth=#{max_depth} show_hidden=#{show_hidden}")
45
+
46
+ root = File.expand_path(path.to_s.empty? ? "." : path)
47
+ return { error: "not a directory: #{path || '.'}" } unless File.directory?(root)
48
+
49
+ depth = max_depth.to_i
50
+ depth = DEFAULT_DEPTH if depth <= 0
51
+
52
+ @count = 0
53
+ @truncated = false
54
+ lines = ["#{File.basename(root)}/"]
55
+ walk(root, depth, show_hidden, "", lines)
56
+ lines << "... (truncated at #{MAX_ENTRIES} entries)" if @truncated
57
+
58
+ lines.join("\n")
59
+ rescue => e
60
+ @logger.error("#{self.class.name} failed: #{e.message}")
61
+ { error: e.message }
62
+ end
63
+
64
+ private
65
+
66
+ def walk(dir, depth_left, show_hidden, indent, lines)
67
+ return if depth_left <= 0 || @truncated
68
+
69
+ entries = children(dir, show_hidden)
70
+ entries.each_with_index do |entry, idx|
71
+ if @count >= MAX_ENTRIES
72
+ @truncated = true
73
+ return
74
+ end
75
+
76
+ full = File.join(dir, entry)
77
+ is_dir = File.directory?(full) && !File.symlink?(full)
78
+ connector = idx == entries.length - 1 ? "└── " : "├── "
79
+ lines << "#{indent}#{connector}#{entry}#{is_dir ? '/' : ''}"
80
+ @count += 1
81
+
82
+ next unless is_dir
83
+
84
+ child_indent = indent + (idx == entries.length - 1 ? " " : "│ ")
85
+ walk(full, depth_left - 1, show_hidden, child_indent, lines)
86
+ end
87
+ end
88
+
89
+ def children(dir, show_hidden)
90
+ entries = Dir.children(dir)
91
+ entries.reject! { |e| e.start_with?(".") } unless show_hidden
92
+ entries.reject! { |e| File.directory?(File.join(dir, e)) && IGNORED_DIRS.include?(e) }
93
+ # directories first, then files, each alphabetical
94
+ entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] }
95
+ rescue SystemCallError
96
+ []
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,12 +1,22 @@
1
1
  # weather_tool.rb - API integration example
2
2
  require 'ruby_llm/tool'
3
- require 'openweathermap'
3
+
4
+ OPENWEATHERMAP_AVAILABLE = begin
5
+ require 'openweathermap'
6
+ true
7
+ rescue LoadError, Gem::LoadError
8
+ false
9
+ end
4
10
 
5
11
  module SharedTools
6
12
  module Tools
7
13
  class WeatherTool < RubyLLM::Tool
8
14
  def self.name = 'weather_tool'
9
15
 
16
+ def available?
17
+ OPENWEATHERMAP_AVAILABLE
18
+ end
19
+
10
20
  description <<~'DESCRIPTION'
11
21
  Retrieve comprehensive current weather information for any city worldwide using the OpenWeatherMap API.
12
22
  This tool provides real-time weather data including temperature, atmospheric conditions, humidity,
@@ -21,7 +31,7 @@ module SharedTools
21
31
  puts "Temperature: #{result[:current][:temperature]}°C"
22
32
  DESCRIPTION
23
33
 
24
- params do
34
+ parameters do
25
35
  string :city, description: <<~DESC.strip
26
36
  Name of the city for weather lookup. Can include city name only (e.g., 'London')
27
37
  or city with country code for better accuracy (e.g., 'London,UK' or 'Paris,FR').
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../shared_tools"
4
+ require_relative "http_helpers"
5
+
6
+ module SharedTools
7
+ module Tools
8
+ # Fetches a URL over http/https and returns its readable text (HTML is
9
+ # stripped to text). Every request and redirect hop passes through
10
+ # UrlGuard, so it can't be pointed at internal/loopback/metadata
11
+ # addresses. Read-only — no authorization prompt required.
12
+ #
13
+ # @example
14
+ # tool = SharedTools::Tools::WebFetchTool.new
15
+ # tool.execute(url: "https://example.com")
16
+ class WebFetchTool < ::RubyLLM::Tool
17
+ include HttpHelpers
18
+
19
+ def self.name = 'web_fetch'
20
+
21
+ description "Fetch a web page or text/JSON resource over http/https and return its readable " \
22
+ "content (HTML is converted to text). Follows redirects safely. Cannot reach " \
23
+ "private, loopback, or cloud-metadata addresses."
24
+
25
+ parameters do
26
+ string :url, description: "The http/https URL to fetch."
27
+ end
28
+
29
+ # @param logger [Logger] optional logger
30
+ def initialize(logger: nil)
31
+ @logger = logger || RubyLLM.logger
32
+ end
33
+
34
+ # @param url [String]
35
+ #
36
+ # @return [String, Hash]
37
+ def execute(url:)
38
+ @logger.info("#{self.class.name}#execute url=#{url.inspect}")
39
+
40
+ response = guarded_get(url)
41
+ return { error: "HTTP #{response.status} from #{response.final_url}" } if response.status >= 400
42
+
43
+ content_type = content_type_of(response)
44
+ text = extract_text(response.body, content_type)
45
+
46
+ header = "GET #{response.final_url} -> #{response.status} (#{content_type})"
47
+ "#{header}\n\n#{text}".strip
48
+ rescue SharedTools::UrlGuard::Blocked => e
49
+ @logger.error("#{self.class.name} url blocked: #{e.message}")
50
+ { error: e.message }
51
+ rescue HttpHelpers::FetchError => e
52
+ @logger.error("#{self.class.name} fetch failed: #{e.message}")
53
+ { error: "fetch failed: #{e.message}" }
54
+ end
55
+
56
+ private
57
+
58
+ def content_type_of(response)
59
+ Array(response.headers["content-type"]).first.to_s.split(";").first.to_s.strip
60
+ end
61
+
62
+ def extract_text(body, content_type)
63
+ text = body.to_s.scrub
64
+ return collapse(text) unless content_type =~ /html|xml/i
65
+
66
+ text = text.dup
67
+ text.gsub!(%r{<script\b[^>]*>.*?</script>}mi, " ")
68
+ text.gsub!(%r{<style\b[^>]*>.*?</style>}mi, " ")
69
+ text.gsub!(/<!--.*?-->/m, " ")
70
+ text.gsub!(%r{</(?:p|div|br|li|h[1-6]|tr|section|article)\s*/?>}i, "\n")
71
+ text.gsub!(/<[^>]+>/, " ")
72
+ collapse(decode_entities(text))
73
+ end
74
+
75
+ def decode_entities(str)
76
+ str.gsub("&amp;", "&").gsub("&lt;", "<").gsub("&gt;", ">")
77
+ .gsub("&quot;", '"').gsub("&#39;", "'").gsub("&nbsp;", " ")
78
+ end
79
+
80
+ def collapse(str)
81
+ str.gsub(/[ \t]+/, " ").gsub(/\n[ \t]*/, "\n").gsub(/\n{3,}/, "\n\n").strip
82
+ end
83
+ end
84
+ end
85
+ end
@@ -27,7 +27,7 @@ module SharedTools
27
27
  All workflow state is persisted to disk and can survive process restarts.
28
28
  DESCRIPTION
29
29
 
30
- params do
30
+ parameters do
31
31
  string :action, description: <<~DESC.strip
32
32
  Workflow management action to perform:
33
33
  - 'start': Initialize a new workflow with initial data and return workflow ID
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+ require 'date'
6
+ require_relative '../../shared_tools'
7
+
8
+ module SharedTools
9
+ module Tools
10
+ # Parses YAML (from a file or an inline string) and either pretty-prints
11
+ # it (as JSON, for readability) or extracts a value with a dot/bracket
12
+ # path. Uses safe_load — no arbitrary Ruby objects are instantiated from
13
+ # the document.
14
+ #
15
+ # Path syntax matches JsonQueryTool: users[0].name, users[].email, config.port
16
+ #
17
+ # @example
18
+ # tool = SharedTools::Tools::YamlQueryTool.new
19
+ # tool.execute(path: "./docker-compose.yml", query: "services[0].image")
20
+ class YamlQueryTool < ::RubyLLM::Tool
21
+ MAX_BYTES = 5 * 1024 * 1024
22
+ PERMITTED = [Date, Time, Symbol].freeze
23
+
24
+ def self.name = 'yaml_query'
25
+
26
+ description "Query YAML with a path expression, or pretty-print it. Provide either a file " \
27
+ "path or an inline yaml string. Loaded safely (no object deserialization). Path " \
28
+ "syntax: keys separated by dots, [n] for array index, [] to map over an array."
29
+
30
+ parameters do
31
+ string :path, description: "YAML file to read. Provide this or yaml.", required: false
32
+ string :yaml, description: "Inline YAML string. Provide this or path.", required: false
33
+ string :query, description: "Path expression to extract, e.g. 'services[0].image'. Omit to pretty-print. Optional.", required: false
34
+ end
35
+
36
+ # @param logger [Logger] optional logger
37
+ def initialize(logger: nil)
38
+ @logger = logger || RubyLLM.logger
39
+ end
40
+
41
+ # @param path [String, nil]
42
+ # @param yaml [String, nil]
43
+ # @param query [String, nil]
44
+ #
45
+ # @return [String, Hash]
46
+ def execute(path: nil, yaml: nil, query: nil)
47
+ @logger.info("#{self.class.name}#execute path=#{path.inspect} query=#{query.inspect}")
48
+
49
+ source = load_source(path, yaml)
50
+ return source if source.is_a?(Hash) # error
51
+
52
+ data = YAML.safe_load(source, permitted_classes: PERMITTED, aliases: true)
53
+ result = query.to_s.strip.empty? ? data : SharedTools::DataPath.query(data, query)
54
+ JSON.pretty_generate(result)
55
+ rescue Psych::DisallowedClass => e
56
+ @logger.error("#{self.class.name} unsafe YAML: #{e.message}")
57
+ { error: "YAML contains a disallowed type: #{e.message}" }
58
+ rescue Psych::SyntaxError => e
59
+ @logger.error("#{self.class.name} invalid YAML: #{e.message}")
60
+ { error: "invalid YAML: #{e.message}" }
61
+ rescue SharedTools::DataPath::Error => e
62
+ @logger.error("#{self.class.name} bad query: #{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
+
69
+ private
70
+
71
+ def load_source(path, yaml)
72
+ if path && !path.to_s.empty?
73
+ return { error: "file not found: #{path}" } unless File.exist?(path)
74
+ return { error: "not a file: #{path}" } unless File.file?(path)
75
+ return { error: "file too large (> #{MAX_BYTES} bytes)" } if File.size(path) > MAX_BYTES
76
+
77
+ File.read(path).scrub
78
+ elsif yaml && !yaml.to_s.empty?
79
+ yaml.to_s
80
+ else
81
+ { error: "provide either path or yaml" }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/tree_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/tree_tool'
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "ipaddr"
5
+ require "resolv"
6
+
7
+ module SharedTools
8
+ # SSRF defense for tools that fetch arbitrary URLs (WebFetchTool,
9
+ # HttpRequestTool, DownloadFileTool). It:
10
+ # - allows only http/https,
11
+ # - rejects embedded credentials,
12
+ # - enforces optional domain allow/deny lists,
13
+ # - resolves the host and blocks the request if ANY resolved address is
14
+ # private, loopback, link-local (incl. the cloud metadata IP), CGNAT,
15
+ # unique-local IPv6, or otherwise reserved.
16
+ #
17
+ # resolve! also returns a vetted IP so the caller can pin the socket to it
18
+ # (Net::HTTP#ipaddr=), closing the DNS-rebinding window: the address that
19
+ # was vetted is exactly the one connected to, while TLS/SNI/cert checks
20
+ # still use the hostname. Re-run resolve! on every redirect hop (the http
21
+ # helper does).
22
+ class UrlGuard
23
+ class Blocked < StandardError; end
24
+
25
+ # A vetted result: the parsed URI plus a concrete IP that every
26
+ # connection should be pinned to, so a second DNS lookup at connect time
27
+ # can't swap in a private address (DNS rebinding).
28
+ Resolution = Struct.new(:uri, :address, keyword_init: true)
29
+
30
+ ALLOWED_SCHEMES = %w[http https].freeze
31
+
32
+ BLOCKED_RANGES = %w[
33
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
34
+ 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.168.0.0/16 198.18.0.0/15
35
+ 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 255.255.255.255/32
36
+ ::1/128 ::/128 fc00::/7 fe80::/10
37
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
38
+
39
+ def initialize(allowlist: [], denylist: [])
40
+ @allowlist = Array(allowlist).map { |d| d.to_s.downcase }
41
+ @denylist = Array(denylist).map { |d| d.to_s.downcase }
42
+ end
43
+
44
+ # Returns a parsed URI if the URL is safe to fetch; raises Blocked
45
+ # otherwise.
46
+ def check!(url)
47
+ resolve!(url).uri
48
+ end
49
+
50
+ # Like check!, but also returns a vetted IP address to pin the connection
51
+ # to (see Resolution). Raises Blocked otherwise.
52
+ def resolve!(url)
53
+ uri = parse(url)
54
+ raise Blocked, "only http/https URLs are allowed" unless ALLOWED_SCHEMES.include?(uri.scheme)
55
+ raise Blocked, "URL must include a host" if uri.host.nil? || uri.host.empty?
56
+ raise Blocked, "URLs with embedded credentials are not allowed" if uri.userinfo
57
+
58
+ host = uri.host.downcase
59
+ enforce_domain_lists(host)
60
+ address = vetted_address(host)
61
+ Resolution.new(uri: uri, address: address)
62
+ end
63
+
64
+ private
65
+
66
+ def parse(url)
67
+ URI.parse(url.to_s)
68
+ rescue URI::InvalidURIError => e
69
+ raise Blocked, "invalid URL: #{e.message}"
70
+ end
71
+
72
+ def enforce_domain_lists(host)
73
+ raise Blocked, "host is denylisted: #{host}" if @denylist.any? { |d| host == d || host.end_with?(".#{d}") }
74
+ return if @allowlist.empty?
75
+
76
+ allowed = @allowlist.any? { |d| host == d || host.end_with?(".#{d}") }
77
+ raise Blocked, "host is not on the allowlist: #{host}" unless allowed
78
+ end
79
+
80
+ # Resolve the host, block the request if ANY resolved address is in a
81
+ # blocked range, and return the first address for the caller to pin to.
82
+ def vetted_address(host)
83
+ addresses = resolve(host)
84
+ raise Blocked, "could not resolve host: #{host}" if addresses.empty?
85
+
86
+ addresses.each do |addr|
87
+ raise Blocked, "host resolves to a blocked address (#{addr})" if blocked_ip?(addr)
88
+ end
89
+ addresses.first
90
+ end
91
+
92
+ def resolve(host)
93
+ Resolv.getaddresses(host)
94
+ rescue StandardError
95
+ []
96
+ end
97
+
98
+ def blocked_ip?(addr)
99
+ ip = IPAddr.new(addr.to_s)
100
+ ip = ip.native if ip.respond_to?(:ipv4_mapped?) && ip.ipv4_mapped?
101
+ BLOCKED_RANGES.any? { |range| range.include?(ip) }
102
+ rescue IPAddr::Error
103
+ true # if we can't parse it, don't fetch it
104
+ end
105
+ end
106
+ end
@@ -15,8 +15,8 @@ module SharedTools
15
15
  # MCP load tracking
16
16
  # ---------------------------------------------------------------------------
17
17
 
18
- # Record the outcome of loading a single MCP client.
19
- # Called by mcp.rb from within each loader thread.
18
+ # Record whether a single MCP server definition is usable (its required
19
+ # environment variables are set). Called by mcp.rb for each server class.
20
20
  #
21
21
  # SharedTools.record_mcp_result("tavily") # success
22
22
  # SharedTools.record_mcp_result("notion", error: e) # failure
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SharedTools
4
- VERSION = "0.4.2"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/web_fetch_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/web_fetch_tool'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ # Shim: require 'shared_tools/yaml_query_tool'
3
+ require 'shared_tools'
4
+ require 'shared_tools/tools/yaml_query_tool'
data/lib/shared_tools.rb CHANGED
@@ -17,8 +17,8 @@ SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/disk.rb")
17
17
  SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/doc.rb")
18
18
  SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/docker.rb")
19
19
  SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/eval.rb")
20
+ SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/git.rb")
20
21
  SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/notification.rb")
21
- SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/version.rb") # Defines VERSION constant, not Version class
22
22
  SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/incomplete") # Empty/incomplete tools directory
23
23
  SharedToolsLoader.ignore("#{__dir__}/shared_tools/tools/enabler.rb") # Experimental; defines Tools::Enabler not SharedTools::Tools::Enabler
24
24