shared_tools 0.4.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +211 -7
- data/lib/shared_tools/apply_patch_tool.rb +4 -0
- data/lib/shared_tools/bash_tool.rb +4 -0
- data/lib/shared_tools/bundle_tool.rb +4 -0
- data/lib/shared_tools/command_guard.rb +35 -0
- data/lib/shared_tools/csv_read_tool.rb +4 -0
- data/lib/shared_tools/csv_write_tool.rb +4 -0
- data/lib/shared_tools/data_path.rb +52 -0
- data/lib/shared_tools/diff_tool.rb +4 -0
- data/lib/shared_tools/download_file_tool.rb +4 -0
- data/lib/shared_tools/gem_tool.rb +4 -0
- data/lib/shared_tools/git_tool.rb +5 -0
- data/lib/shared_tools/glob_tool.rb +4 -0
- data/lib/shared_tools/http_request_tool.rb +4 -0
- data/lib/shared_tools/json_query_tool.rb +4 -0
- data/lib/shared_tools/lint_tool.rb +4 -0
- data/lib/shared_tools/mcp/brave_search_client.rb +18 -16
- data/lib/shared_tools/mcp/chart_client.rb +18 -15
- data/lib/shared_tools/mcp/client_support.rb +103 -0
- data/lib/shared_tools/mcp/github_client.rb +23 -19
- data/lib/shared_tools/mcp/hugging_face_client.rb +22 -22
- data/lib/shared_tools/mcp/memory_client.rb +20 -15
- data/lib/shared_tools/mcp/notion_client.rb +21 -19
- data/lib/shared_tools/mcp/playwright_client.rb +19 -15
- data/lib/shared_tools/mcp/sequential_thinking_client.rb +17 -14
- data/lib/shared_tools/mcp/slack_client.rb +30 -25
- data/lib/shared_tools/mcp/tavily_client.rb +21 -21
- data/lib/shared_tools/mcp.rb +32 -41
- data/lib/shared_tools/multi_edit_tool.rb +4 -0
- data/lib/shared_tools/parse_ruby_tool.rb +4 -0
- data/lib/shared_tools/process_kill_tool.rb +4 -0
- data/lib/shared_tools/process_list_tool.rb +4 -0
- data/lib/shared_tools/process_output_tool.rb +4 -0
- data/lib/shared_tools/process_registry.rb +224 -0
- data/lib/shared_tools/process_runner.rb +69 -0
- data/lib/shared_tools/process_start_tool.rb +4 -0
- data/lib/shared_tools/python_tests_tool.rb +4 -0
- data/lib/shared_tools/replace_in_files_tool.rb +4 -0
- data/lib/shared_tools/ruby_outline.rb +207 -0
- data/lib/shared_tools/run_tests_tool.rb +4 -0
- data/lib/shared_tools/text_diff.rb +80 -0
- data/lib/shared_tools/todo_write_tool.rb +4 -0
- data/lib/shared_tools/toml_parser.rb +409 -0
- data/lib/shared_tools/toml_query_tool.rb +4 -0
- data/lib/shared_tools/tools/apply_patch_tool.rb +122 -0
- data/lib/shared_tools/tools/bash_tool.rb +102 -0
- data/lib/shared_tools/tools/browser/click_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/page_screenshot_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/selector_inspect_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/text_field_area_set_tool.rb +1 -1
- data/lib/shared_tools/tools/browser/visit_tool.rb +1 -1
- data/lib/shared_tools/tools/browser_tool.rb +1 -1
- data/lib/shared_tools/tools/bundle_tool.rb +96 -0
- data/lib/shared_tools/tools/calculator_tool.rb +1 -1
- data/lib/shared_tools/tools/clipboard_tool.rb +1 -1
- data/lib/shared_tools/tools/composite_analysis_tool.rb +1 -1
- data/lib/shared_tools/tools/computer_tool.rb +1 -1
- data/lib/shared_tools/tools/cron_tool.rb +1 -1
- data/lib/shared_tools/tools/csv_read_tool.rb +82 -0
- data/lib/shared_tools/tools/csv_write_tool.rb +67 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +1 -1
- data/lib/shared_tools/tools/data_science_kit.rb +1 -1
- data/lib/shared_tools/tools/database_query_tool.rb +1 -1
- data/lib/shared_tools/tools/database_tool.rb +1 -1
- data/lib/shared_tools/tools/diff_tool.rb +48 -0
- data/lib/shared_tools/tools/disk/directory_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_list_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/directory_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_create_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_delete_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_move_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_read_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_replace_tool.rb +1 -1
- data/lib/shared_tools/tools/disk/file_write_tool.rb +1 -1
- data/lib/shared_tools/tools/disk_tool.rb +1 -1
- data/lib/shared_tools/tools/dns_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/docx_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/pdf_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/spreadsheet_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc/text_reader_tool.rb +1 -1
- data/lib/shared_tools/tools/doc_tool.rb +1 -1
- data/lib/shared_tools/tools/docker/compose_run_tool.rb +1 -1
- data/lib/shared_tools/tools/download_file_tool.rb +87 -0
- data/lib/shared_tools/tools/enabler.rb +2 -2
- data/lib/shared_tools/tools/error_handling_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/python_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/ruby_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval/shell_eval_tool.rb +1 -1
- data/lib/shared_tools/tools/eval_tool.rb +1 -1
- data/lib/shared_tools/tools/gem_tool.rb +157 -0
- data/lib/shared_tools/tools/git/add_tool.rb +73 -0
- data/lib/shared_tools/tools/git/blame_tool.rb +77 -0
- data/lib/shared_tools/tools/git/branch_tool.rb +55 -0
- data/lib/shared_tools/tools/git/checkout_tool.rb +69 -0
- data/lib/shared_tools/tools/git/commit_tool.rb +73 -0
- data/lib/shared_tools/tools/git/diff_tool.rb +69 -0
- data/lib/shared_tools/tools/git/grep_tool.rb +82 -0
- data/lib/shared_tools/tools/git/helpers.rb +70 -0
- data/lib/shared_tools/tools/git/log_tool.rb +71 -0
- data/lib/shared_tools/tools/git/show_tool.rb +66 -0
- data/lib/shared_tools/tools/git/status_tool.rb +46 -0
- data/lib/shared_tools/tools/git.rb +18 -0
- data/lib/shared_tools/tools/git_tool.rb +148 -0
- data/lib/shared_tools/tools/glob_tool.rb +75 -0
- data/lib/shared_tools/tools/http_helpers.rb +119 -0
- data/lib/shared_tools/tools/http_request_tool.rb +91 -0
- data/lib/shared_tools/tools/json_query_tool.rb +81 -0
- data/lib/shared_tools/tools/lint_tool.rb +93 -0
- data/lib/shared_tools/tools/multi_edit_tool.rb +134 -0
- data/lib/shared_tools/tools/notification_tool.rb +1 -1
- data/lib/shared_tools/tools/parse_ruby_tool.rb +127 -0
- data/lib/shared_tools/tools/process_kill_tool.rb +55 -0
- data/lib/shared_tools/tools/process_list_tool.rb +41 -0
- data/lib/shared_tools/tools/process_output_tool.rb +69 -0
- data/lib/shared_tools/tools/process_start_tool.rb +0 -0
- data/lib/shared_tools/tools/python_tests_tool.rb +105 -0
- data/lib/shared_tools/tools/replace_in_files_tool.rb +166 -0
- data/lib/shared_tools/tools/run_tests_tool.rb +108 -0
- data/lib/shared_tools/tools/search_codebase_tool.rb +178 -47
- data/lib/shared_tools/tools/secure_tool_template.rb +1 -1
- data/lib/shared_tools/tools/system_info_tool.rb +1 -1
- data/lib/shared_tools/tools/todo_write_tool.rb +81 -0
- data/lib/shared_tools/tools/toml_query_tool.rb +81 -0
- data/lib/shared_tools/tools/toolchain_helpers.rb +64 -0
- data/lib/shared_tools/tools/tree_tool.rb +100 -0
- data/lib/shared_tools/tools/weather_tool.rb +1 -1
- data/lib/shared_tools/tools/web_fetch_tool.rb +85 -0
- data/lib/shared_tools/tools/workflow_manager_tool.rb +1 -1
- data/lib/shared_tools/tools/yaml_query_tool.rb +86 -0
- data/lib/shared_tools/tree_tool.rb +4 -0
- data/lib/shared_tools/url_guard.rb +106 -0
- data/lib/shared_tools/utilities.rb +2 -2
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools/web_fetch_tool.rb +4 -0
- data/lib/shared_tools/yaml_query_tool.rb +4 -0
- data/lib/shared_tools.rb +1 -1
- metadata +516 -409
- data/lib/shared_tools/mcp/streamable_http_patch.rb +0 -42
- data/lib/shared_tools/tools/version.rb +0 -7
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
77
|
+
.gsub(""", '"').gsub("'", "'").gsub(" ", " ")
|
|
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
|
-
|
|
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,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
|
|
19
|
-
# Called by mcp.rb
|
|
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
|
data/lib/shared_tools/version.rb
CHANGED
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
|
|