a2a-rails 0.2.0.rc2 → 0.3.0.rc1

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.
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "resolv"
5
+ require "timeout"
6
+ require "uri"
7
+ require_relative "../errors"
8
+
9
+ module A2A
10
+ module Rails
11
+ class Client
12
+ # Preflight checks for a *configured* outbound Agent Card or RPC URL.
13
+ #
14
+ # IMPORTANT: Resolve! alone does not prevent DNS rebinding. The future
15
+ # transport MUST connect to one of Target#addresses while preserving
16
+ # the validated Host/SNI, and repeat the decision for each connection.
17
+ # Do not expose this as a production-ready HTTP Client.
18
+ class OutboundPolicy
19
+ class RejectedTarget < A2A::Rails::Error
20
+ attr_reader :reason
21
+
22
+ def initialize(reason)
23
+ @reason = reason
24
+ super("Outbound A2A target rejected: #{reason}")
25
+ end
26
+ end
27
+
28
+ Target = Struct.new(:url, :origin, :host, :port, :addresses, keyword_init: true)
29
+
30
+ # IPv4-pinned policy. Dual-stack DNS is accepted only if EVERY IPv6
31
+ # answer is a recognized public global-unicast candidate and at least
32
+ # one safe IPv4 address is present. IPv6 sockets remain unsupported.
33
+ BLOCKED_IPV4 = %w[
34
+ 0.0.0.0/8
35
+ 10.0.0.0/8
36
+ 100.64.0.0/10
37
+ 127.0.0.0/8
38
+ 169.254.0.0/16
39
+ 172.16.0.0/12
40
+ 192.0.0.0/24
41
+ 192.0.2.0/24
42
+ 192.88.99.0/24
43
+ 192.168.0.0/16
44
+ 198.18.0.0/15
45
+ 198.51.100.0/24
46
+ 203.0.113.0/24
47
+ 224.0.0.0/4
48
+ 240.0.0.0/4
49
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
50
+
51
+ # Only recognize IPv6 global-unicast candidates for DNS validation;
52
+ # outbound sockets remain IPv4-only. Exclude special-use allocations
53
+ # conservatively, including IPv4-embedding transition mechanisms.
54
+ GLOBAL_UNICAST_IPV6 = IPAddr.new("2000::/3")
55
+ BLOCKED_IPV6 = %w[
56
+ 2001::/23
57
+ 2001:db8::/32
58
+ 2002::/16
59
+ 3fff::/20
60
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
61
+
62
+ HOST_LABEL = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/
63
+
64
+ # Resolver injection is for unit testing only; normal callers use
65
+ # Resolv. The Client facade will not expose this override by default.
66
+ def initialize(allowed_origins:, resolver: Resolv.method(:getaddresses))
67
+ unless allowed_origins.is_a?(Array) && !allowed_origins.empty? && allowed_origins.all? { |value| value.is_a?(String) }
68
+ raise RejectedTarget, :invalid_allowlist
69
+ end
70
+ unless resolver.respond_to?(:call)
71
+ raise RejectedTarget, :invalid_resolver
72
+ end
73
+
74
+ @allowed_origins = allowed_origins.map { |value| checked_origin(value) }.uniq.freeze
75
+ @resolver = resolver
76
+ end
77
+
78
+ # Check configured URL syntax and exact origin without resolving DNS.
79
+ # The actual HTTP transport MUST call resolve! again at connect time.
80
+ def validate_url!(value)
81
+ uri = checked_uri(value)
82
+ raise RejectedTarget, :unapproved_origin unless @allowed_origins.include?(canonical_origin(uri))
83
+
84
+ uri
85
+ end
86
+
87
+ # Called for BOTH the configured Agent Card URL and each card-provided
88
+ # supportedInterfaces[].url. The result is NOT permission to connect
89
+ # by hostname: use Target#addresses as the connect-time allowlist.
90
+ def resolve!(value)
91
+ uri = checked_uri(value)
92
+ origin = canonical_origin(uri)
93
+ raise RejectedTarget, :unapproved_origin unless @allowed_origins.include?(origin)
94
+
95
+ addresses = begin
96
+ @resolver.call(uri.host)
97
+ rescue Timeout::Error
98
+ # The transport's total deadline must not be converted into a DNS failure.
99
+ raise
100
+ rescue StandardError
101
+ raise RejectedTarget.new(:dns_failure), cause: nil
102
+ end
103
+ unless addresses.is_a?(Array) && !addresses.empty?
104
+ raise RejectedTarget, :dns_failure
105
+ end
106
+
107
+ # Fail closed if ANY DNS answer is unsafe, even when another answer
108
+ # is public. Public IPv6 answers are checked, but NEVER selected as
109
+ # a socket destination; the transport pins an approved IPv4 only.
110
+ addresses = addresses.each_with_object([]) do |address, ipv4_addresses|
111
+ # IPAddr accepts CIDR strings and normalizes them to network addresses.
112
+ # DNS answers must instead be individual host IP literals.
113
+ unless address.is_a?(String) && !address.include?("/")
114
+ raise RejectedTarget, :invalid_dns_address
115
+ end
116
+ parsed = IPAddr.new(address)
117
+ if parsed.ipv4?
118
+ raise RejectedTarget, :blocked_ip if BLOCKED_IPV4.any? { |range| range.include?(parsed) }
119
+ ipv4_addresses << parsed.to_s.freeze
120
+ elsif !GLOBAL_UNICAST_IPV6.include?(parsed) ||
121
+ BLOCKED_IPV6.any? { |range| range.include?(parsed) }
122
+ raise RejectedTarget, :blocked_ip
123
+ end
124
+ rescue IPAddr::InvalidAddressError, ArgumentError
125
+ raise RejectedTarget.new(:invalid_dns_address), cause: nil
126
+ end.uniq.freeze
127
+ raise RejectedTarget, :unsupported_ip_family if addresses.empty?
128
+
129
+ Target.new(
130
+ url: uri.to_s.freeze,
131
+ origin: origin.freeze,
132
+ host: uri.host.freeze,
133
+ port: uri.port,
134
+ addresses: addresses
135
+ ).freeze
136
+ end
137
+
138
+ private
139
+
140
+ def checked_origin(value)
141
+ uri = checked_uri(value)
142
+ raise RejectedTarget, :invalid_allowlist unless uri.path.empty? || uri.path == "/"
143
+
144
+ canonical_origin(uri)
145
+ end
146
+
147
+ def checked_uri(value)
148
+ raise RejectedTarget, :invalid_url unless value.is_a?(String) && !value.empty? && value == value.strip
149
+ uri = URI.parse(value)
150
+
151
+ unless uri.is_a?(URI::HTTPS) && uri.host && !uri.host.empty? &&
152
+ uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
153
+ raise RejectedTarget, :invalid_url
154
+ end
155
+
156
+ host = uri.host.downcase
157
+ labels = host.split(".", -1)
158
+ unless host.ascii_only? && host.bytesize <= 253 && labels.size >= 2 &&
159
+ labels.all? { |part| HOST_LABEL.match?(part) } &&
160
+ labels.last.match?(/[a-z]/)
161
+ raise RejectedTarget, :invalid_host
162
+ end
163
+
164
+ # DNS names only, never direct IPv4/IPv6 or unusual numeric forms.
165
+ begin
166
+ IPAddr.new(host)
167
+ raise RejectedTarget, :ip_literal
168
+ rescue IPAddr::InvalidAddressError
169
+ # Expected for DNS names.
170
+ end
171
+
172
+ # Fail closed on encoded slashes, backslashes, controls or ambiguous
173
+ # dot segments pending a more general URL canonicalization layer.
174
+ path = uri.path
175
+ if path.match?(/[%\\\\[:cntrl:]]/) || path.split("/").include?("..") || path.split("/").include?(".")
176
+ raise RejectedTarget, :invalid_path
177
+ end
178
+
179
+ unless uri.port.between?(1, 65_535)
180
+ raise RejectedTarget, :invalid_port
181
+ end
182
+
183
+ uri.host = host
184
+ uri
185
+ rescue URI::InvalidURIError, URI::InvalidComponentError
186
+ raise RejectedTarget.new(:invalid_url), cause: nil
187
+ end
188
+
189
+ def canonical_origin(uri)
190
+ base = "https://#{uri.host.downcase}"
191
+ uri.port == 443 ? base : "#{base}:#{uri.port}"
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "timeout"
7
+ require "uri"
8
+ require_relative "outbound_policy"
9
+
10
+ module A2A
11
+ module Rails
12
+ class Client
13
+ # Internal synchronous HTTPS transport used by the unreleased Client facade.
14
+ #
15
+ # DNS is checked immediately before each request; the selected IP is
16
+ # pinned on Net::HTTP BEFORE the socket is opened. TLS peer and hostname
17
+ # validation continue to use the original approved DNS hostname.
18
+ class PinnedHttpsTransport
19
+ class Error < A2A::Rails::Error
20
+ attr_reader :reason
21
+
22
+ def initialize(reason)
23
+ @reason = reason
24
+ super("Outbound A2A HTTP request failed: #{reason}")
25
+ end
26
+ end
27
+
28
+ class RedirectBlocked < Error; end
29
+ class ResponseTooLarge < Error; end
30
+ class InvalidResponse < Error; end
31
+ class DeadlineExceeded < Error; end
32
+
33
+ class HTTPError < Error
34
+ attr_reader :status
35
+
36
+ def initialize(status)
37
+ @status = status
38
+ super(:http_error)
39
+ end
40
+ end
41
+
42
+ Response = Struct.new(:status, :json, keyword_init: true)
43
+
44
+ DEFAULT_OPEN_TIMEOUT = 3
45
+ DEFAULT_READ_TIMEOUT = 10
46
+ DEFAULT_TOTAL_TIMEOUT = 15
47
+ DEFAULT_MAX_BYTES = 1_048_576
48
+ DEFAULT_MAX_DEPTH = 48
49
+ MAX_CREDENTIAL_BYTES = 4096
50
+
51
+ def initialize(policy:, open_timeout: DEFAULT_OPEN_TIMEOUT,
52
+ read_timeout: DEFAULT_READ_TIMEOUT,
53
+ total_timeout: DEFAULT_TOTAL_TIMEOUT,
54
+ max_request_bytes: DEFAULT_MAX_BYTES,
55
+ max_response_bytes: DEFAULT_MAX_BYTES,
56
+ max_json_nesting: DEFAULT_MAX_DEPTH,
57
+ ca_file: nil)
58
+ unless policy.respond_to?(:resolve!)
59
+ raise Error, :invalid_policy
60
+ end
61
+
62
+ @policy = policy
63
+ @open_timeout = positive_number!(open_timeout)
64
+ @read_timeout = positive_number!(read_timeout)
65
+ @total_timeout = positive_number!(total_timeout)
66
+ @max_request_bytes = positive_integer!(max_request_bytes)
67
+ @max_response_bytes = positive_integer!(max_response_bytes)
68
+ @max_json_nesting = positive_integer!(max_json_nesting)
69
+ @ca_file = ca_file
70
+ end
71
+
72
+ # Agent Card requests are public by default. Protected Cards require
73
+ # their OWN callback and explicitly bound credential origin.
74
+ def get_json(url:, authorization: nil, credential_origin: nil)
75
+ perform(:get, url: url, authorization: authorization, credential_origin: credential_origin)
76
+ end
77
+
78
+ # A2A v1.0 JSON-RPC payload; preserves both Task and direct Message
79
+ # response shapes without interpreting the protocol at this layer.
80
+ def post_json(url:, json:, authorization: nil, credential_origin: nil)
81
+ encoded = JSON.generate(json, max_nesting: @max_json_nesting, allow_nan: false)
82
+ raise Error, :request_too_large if encoded.bytesize > @max_request_bytes
83
+
84
+ perform(:post, url: url, body: encoded,
85
+ authorization: authorization, credential_origin: credential_origin)
86
+ rescue JSON::GeneratorError, JSON::NestingError, TypeError
87
+ raise Error.new(:invalid_request_json), cause: nil
88
+ end
89
+
90
+ private
91
+
92
+ def perform(method, url:, body: nil, authorization: nil, credential_origin: nil)
93
+ # Fail closed on syntax/origin/DNS before evaluating any credential.
94
+ # OutboundPolicy also rejects mixed public/private DNS results.
95
+ # DNS and credential callbacks count toward the same total deadline
96
+ # as socket connection, request upload and response consumption.
97
+ Timeout.timeout(@total_timeout) do
98
+ target = @policy.resolve!(url)
99
+ uri = URI.parse(target.url)
100
+ raise Error, :invalid_target unless uri.is_a?(URI::HTTPS) &&
101
+ target.host == uri.host && target.port == uri.port &&
102
+ target.addresses.is_a?(Array) && !target.addresses.empty?
103
+
104
+ header = credential_for(target, authorization, credential_origin)
105
+ ip = target.addresses.first
106
+
107
+ # The third argument nil is essential: ignore HTTP(S)_PROXY and
108
+ # env proxy configuration, which could bypass the approved IP.
109
+ http = Net::HTTP.new(target.host, target.port, nil)
110
+ raise Error, :proxy_not_disabled if http.proxy?
111
+
112
+ http.ipaddr = ip
113
+ http.use_ssl = true
114
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
115
+ http.verify_hostname = true
116
+ http.ca_file = @ca_file unless @ca_file.nil?
117
+ http.open_timeout = [@open_timeout, @total_timeout].min
118
+ http.read_timeout = [@read_timeout, @total_timeout].min
119
+ http.write_timeout = [@read_timeout, @total_timeout].min
120
+ http.max_retries = 0 # Especially important for SendMessage.
121
+ http.keep_alive_timeout = 0
122
+
123
+ request_class = method == :get ? Net::HTTP::Get : Net::HTTP::Post
124
+ request = request_class.new(uri.request_uri)
125
+ request["Accept"] = "application/json"
126
+ request["Accept-Encoding"] = "identity"
127
+ request["A2A-Version"] = "1.0" if method == :post
128
+ request["Content-Type"] = "application/json" if method == :post
129
+ request["Authorization"] = header unless header.nil?
130
+ request.body = body if method == :post
131
+
132
+ payload = nil
133
+ status = nil
134
+ http.start do |connection|
135
+ # Guard against a future Net::HTTP implementation that might
136
+ # silently connect anywhere other than the pinned address.
137
+ raise Error, :socket_address_mismatch unless connection.ipaddr == ip
138
+
139
+ connection.request(request) do |response|
140
+ status = response.code.to_i
141
+ raise RedirectBlocked, :redirect_rejected if status.between?(300, 399)
142
+ raise HTTPError, status unless status.between?(200, 299)
143
+
144
+ content_type = response["Content-Type"].to_s.downcase.split(";", 2).first.strip
145
+ unless ["application/json", "application/a2a+json"].include?(content_type)
146
+ raise InvalidResponse, :invalid_content_type
147
+ end
148
+ unless response["Content-Encoding"].to_s.strip.downcase.match?(/\A(?:|identity)\z/)
149
+ raise InvalidResponse, :unsupported_content_encoding
150
+ end
151
+
152
+ content_length = response["Content-Length"]
153
+ if content_length
154
+ raise InvalidResponse, :invalid_content_length unless content_length.match?(/\A[0-9]+\z/)
155
+ raise ResponseTooLarge, :response_too_large if content_length.to_i > @max_response_bytes
156
+ end
157
+
158
+ payload = +""
159
+ response.read_body do |chunk|
160
+ raise ResponseTooLarge, :response_too_large if payload.bytesize + chunk.bytesize > @max_response_bytes
161
+ payload << chunk
162
+ end
163
+
164
+ # Net::HTTP can return successfully on a prematurely closed
165
+ # Content-Length body. A valid JSON prefix must not make that
166
+ # truncated HTTP response look like a complete A2A reply.
167
+ if content_length && payload.bytesize != content_length.to_i
168
+ raise InvalidResponse, :invalid_content_length
169
+ end
170
+ end
171
+ end
172
+
173
+ parsed = JSON.parse(payload, max_nesting: @max_json_nesting)
174
+ Response.new(status: status, json: parsed).freeze
175
+ end
176
+ rescue Timeout::Error, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
177
+ raise DeadlineExceeded.new(:timeout), cause: nil
178
+ rescue JSON::ParserError, JSON::NestingError
179
+ raise InvalidResponse.new(:invalid_json), cause: nil
180
+ rescue OpenSSL::SSL::SSLError, IOError, SystemCallError, SocketError, EOFError,
181
+ Net::HTTPBadResponse, Net::ProtocolError
182
+ # Never surface remote bodies, headers, URLs, IPs or credentials,
183
+ # including nested exception causes captured by error reporters.
184
+ raise Error.new(:connection_failed), cause: nil
185
+ end
186
+
187
+ def credential_for(target, callback, origin)
188
+ if callback.nil?
189
+ raise Error, :unexpected_credential_origin unless origin.nil?
190
+ return nil
191
+ end
192
+ unless callback.respond_to?(:call) && origin.is_a?(String) && origin == target.origin
193
+ raise Error, :credential_origin_mismatch
194
+ end
195
+
196
+ value = begin
197
+ callback.call
198
+ rescue Timeout::Error
199
+ # Preserve the outer total-deadline classification.
200
+ raise
201
+ rescue StandardError
202
+ # Host token-provider exceptions may embed credentials. Never
203
+ # surface them as the transport's public-facing error/cause.
204
+ raise Error.new(:credential_failure), cause: nil
205
+ end
206
+ unless value.is_a?(String) && !value.empty? && value.bytesize <= MAX_CREDENTIAL_BYTES &&
207
+ !value.match?(/[\r\n\x00-\x1f\x7f]/)
208
+ raise Error, :invalid_credential
209
+ end
210
+ value
211
+ end
212
+
213
+ def positive_number!(value)
214
+ return value if value.is_a?(Numeric) && value.finite? && value.positive?
215
+
216
+ raise Error, :invalid_timeout
217
+ end
218
+
219
+ def positive_integer!(value)
220
+ return value if value.is_a?(Integer) && value.positive?
221
+
222
+ raise Error, :invalid_limit
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A2A
4
+ module Rails
5
+ class Client
6
+ # Public client-only exceptions. Never include response bodies, URLs,
7
+ # request metadata or credential strings in exception messages.
8
+ class Error < A2A::Rails::Error
9
+ attr_reader :reason, :operation
10
+
11
+ def initialize(reason, operation: nil)
12
+ @reason = reason
13
+ @operation = operation
14
+ super("Outbound A2A #{operation || 'Client'}: #{reason}")
15
+ end
16
+ end
17
+
18
+ class ConfigurationError < Error; end
19
+ class InvalidInputError < Error; end
20
+ class DiscoveryError < Error; end
21
+ class UnsupportedInterfaceError < DiscoveryError; end
22
+ class TransportError < Error; end
23
+
24
+ class TimeoutError < TransportError
25
+ attr_reader :may_have_executed
26
+
27
+ def initialize(operation:, may_have_executed:)
28
+ @may_have_executed = may_have_executed
29
+ super(:timeout, operation: operation)
30
+ end
31
+ end
32
+
33
+ class AuthenticationError < Error
34
+ attr_reader :status
35
+
36
+ def initialize(status, operation:)
37
+ @status = status
38
+ super(:authentication_rejected, operation: operation)
39
+ end
40
+ end
41
+
42
+ class RemoteError < Error
43
+ attr_reader :code
44
+
45
+ def initialize(code, operation:)
46
+ @code = code
47
+ super(:remote_protocol_error, operation: operation)
48
+ end
49
+ end
50
+
51
+ class InvalidResponseError < Error; end
52
+
53
+ # Immutable plain-Ruby results, deliberately independent of the SDK.
54
+ SendResult = Struct.new(:kind, :task, :message, keyword_init: true)
55
+ ListResult = Struct.new(:tasks, :next_page_token, :page_size, :total_size, keyword_init: true)
56
+ end
57
+ end
58
+ end