require 'agents' class Captain::Tools::HttpTool < Agents::Tool def initialize(assistant, custom_tool) @assistant = assistant @custom_tool = custom_tool super() end def active? @custom_tool.enabled? end def execute(*args, **params) tool_context, remaining_args = extract_tool_context(args) actual_params = resolve_params(remaining_args, params) perform(tool_context, **actual_params.symbolize_keys) end def perform(tool_context, **params) url = @custom_tool.build_request_url(params) body = @custom_tool.build_request_body(params) response = execute_http_request(url, body, tool_context) @custom_tool.format_response(response.body) rescue StandardError => e Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}") 'An error occurred while executing the request' end def test_perform(tool_context, **params) url = @custom_tool.build_request_url(params) body = @custom_tool.build_request_body(params) # Execute request response = execute_http_request(url, body, tool_context) # Return structured data for test UI { success: response.is_a?(Net::HTTPSuccess), status: response.code.to_i, headers: mask_sensitive_headers(response.each_header.to_h), body: response.body # Frontend should truncated if too large } rescue StandardError => e { success: false, error: e.message, status: 500 } end private def resolve_params(args, params) if args.first.is_a?(Hash) && params.empty? args.first else params end.with_indifferent_access end def extract_tool_context(args) return [nil, []] if args.empty? first = args.first if first.respond_to?(:state) || first.respond_to?(:context) [first, args.drop(1)] else [nil, args] end end def mask_sensitive_headers(headers) sensitive_keys = %w[authorization plug-play-token plug-play-id x-api-key] headers.each_with_object({}) do |(k, v), h| h[k] = if sensitive_keys.include?(k.downcase) '********' else v end end end PRIVATE_IP_RANGES = [ IPAddr.new('127.0.0.0/8'), # IPv4 Loopback IPAddr.new('10.0.0.0/8'), # IPv4 Private network IPAddr.new('172.16.0.0/12'), # IPv4 Private network IPAddr.new('192.168.0.0/16'), # IPv4 Private network IPAddr.new('169.254.0.0/16'), # IPv4 Link-local IPAddr.new('::1'), # IPv6 Loopback IPAddr.new('fc00::/7'), # IPv6 Unique local addresses IPAddr.new('fe80::/10') # IPv6 Link-local ].freeze # Limit response size to prevent memory exhaustion and match LLM token limits # 1MB of text ≈ 250K tokens, which exceeds most LLM context windows MAX_RESPONSE_SIZE = 1.megabyte def execute_http_request(url, body, tool_context) uri = URI.parse(url) # Check if resolved IP is private check_private_ip!(uri.host) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == 'https' http.read_timeout = 30 http.open_timeout = 10 http.max_retries = 0 # Disable redirects request = build_http_request(uri, body) apply_authentication(request) apply_metadata_headers(request, tool_context) Rails.logger.info "[HttpTool] Requesting #{request.method} #{uri}" Rails.logger.info "[HttpTool] Headers: #{request.each_header.to_h}" Rails.logger.info "[HttpTool] Body: #{request.body}" if request.body response = http.request(request) Rails.logger.info "[HttpTool] Response Status: #{response.code}" Rails.logger.info "[HttpTool] Response Body: #{response.body}" raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess) validate_response!(response) response end def check_private_ip!(hostname) ip_address = IPAddr.new(Resolv.getaddress(hostname)) raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) } rescue Resolv::ResolvError, SocketError => e raise "DNS resolution failed: #{e.message}" end def validate_response!(response) content_length = response['content-length']&.to_i if content_length && content_length > MAX_RESPONSE_SIZE raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" end return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" end def build_http_request(uri, body) if @custom_tool.http_method == 'POST' request = Net::HTTP::Post.new(uri.request_uri) if body request.body = body request['Content-Type'] = 'application/json' end else request = Net::HTTP::Get.new(uri.request_uri) end request end def apply_authentication(request) headers = @custom_tool.build_auth_headers headers.each { |key, value| request[key] = value } credentials = @custom_tool.build_basic_auth_credentials request.basic_auth(*credentials) if credentials end def apply_metadata_headers(request, tool_context) state = tool_context&.state || {} metadata_headers = @custom_tool.build_metadata_headers(state) metadata_headers.each { |key, value| request[key] = value } end end