mistri 0.5.0 → 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 +469 -4
- data/CONTRIBUTING.md +52 -0
- data/README.md +289 -385
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/agent.rb +575 -55
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +72 -16
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +34 -11
- data/lib/mistri/console.rb +28 -7
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +14 -12
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +24 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks.rb +3 -3
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +42 -8
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +26 -10
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +1 -1
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +560 -48
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/spawner.rb +111 -61
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +1 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +124 -87
- data/lib/mistri/task_output.rb +24 -6
- data/lib/mistri/tool.rb +93 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +4 -2
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +6 -1
- data/mistri.gemspec +34 -0
- metadata +31 -3
data/lib/mistri/event.rb
CHANGED
|
@@ -11,15 +11,18 @@ module Mistri
|
|
|
11
11
|
# message's content list.
|
|
12
12
|
# origin names the sub-agent an event came from: nil for this agent's own
|
|
13
13
|
# turns, and nesting joins names left to right ("researcher>writer").
|
|
14
|
-
# duration is the tool
|
|
15
|
-
#
|
|
14
|
+
# duration is the measured tool execution time on :tool_result events; nil
|
|
15
|
+
# means no duration is available, not necessarily that no side effect ran.
|
|
16
|
+
# tool_error is explicit on :tool_result and does not change Event#error?,
|
|
17
|
+
# which means a terminal provider-turn failure.
|
|
16
18
|
class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
|
|
17
19
|
:reason, :message, :error_message, :partial, :origin,
|
|
18
20
|
:duration, :attempt, :max_attempts, :delay,
|
|
19
|
-
:agent, :session_id, :status)
|
|
21
|
+
:agent, :session_id, :status, :tool_error)
|
|
20
22
|
# The stream types come from a provider mid-turn; the loop adds
|
|
21
|
-
# :
|
|
22
|
-
# call parks for a human,
|
|
23
|
+
# :tool_started when a resolved tool commits to execution, :tool_result
|
|
24
|
+
# after it finishes, :approval_needed when a gated call parks for a human,
|
|
25
|
+
# :compacting/:compaction around a context
|
|
23
26
|
# compaction, and :retry (with attempt, max_attempts, delay) before it
|
|
24
27
|
# waits out a transient failure, so one subscription sees the whole
|
|
25
28
|
# exchange. :done and :error are loop-owned and terminal: only the
|
|
@@ -33,7 +36,7 @@ module Mistri
|
|
|
33
36
|
thinking_start thinking_delta thinking_end
|
|
34
37
|
toolcall_start toolcall_delta toolcall_end
|
|
35
38
|
done error
|
|
36
|
-
tool_result approval_needed
|
|
39
|
+
tool_started tool_result approval_needed
|
|
37
40
|
compacting compaction
|
|
38
41
|
retry
|
|
39
42
|
subagent_report
|
|
@@ -42,9 +45,20 @@ module Mistri
|
|
|
42
45
|
def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
|
|
43
46
|
reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
|
|
44
47
|
duration: nil, attempt: nil, max_attempts: nil, delay: nil,
|
|
45
|
-
agent: nil, session_id: nil, status: nil)
|
|
48
|
+
agent: nil, session_id: nil, status: nil, tool_error: nil)
|
|
46
49
|
raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
|
|
47
50
|
|
|
51
|
+
if type == :tool_result
|
|
52
|
+
unless [true, false].include?(tool_error)
|
|
53
|
+
raise ArgumentError, "tool_result events require a boolean tool_error"
|
|
54
|
+
end
|
|
55
|
+
elsif !tool_error.nil?
|
|
56
|
+
raise ArgumentError, "tool_error is only valid on tool_result events"
|
|
57
|
+
end
|
|
58
|
+
if type == :tool_result && message && message.tool_error != tool_error
|
|
59
|
+
raise ArgumentError, "event tool_error must match its message"
|
|
60
|
+
end
|
|
61
|
+
|
|
48
62
|
super
|
|
49
63
|
end
|
|
50
64
|
|
|
@@ -52,13 +66,15 @@ module Mistri
|
|
|
52
66
|
|
|
53
67
|
def error? = type == :error
|
|
54
68
|
|
|
69
|
+
def tool_error? = tool_error == true
|
|
70
|
+
|
|
55
71
|
def terminal? = done? || error?
|
|
56
72
|
|
|
57
73
|
# Partials are ephemeral streaming state and stay out of serialization.
|
|
58
74
|
def to_h
|
|
59
75
|
{ type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
|
|
60
76
|
reason:, message: message&.to_h, error_message:, origin:, duration:,
|
|
61
|
-
attempt:, max_attempts:, delay:, agent:, session_id:, status: }.compact
|
|
77
|
+
attempt:, max_attempts:, delay:, agent:, session_id:, status:, tool_error: }.compact
|
|
62
78
|
end
|
|
63
79
|
end
|
|
64
80
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Preserves subscriber exception identity across internal rescue boundaries.
|
|
5
|
+
module EventDelivery
|
|
6
|
+
# Tags a subscriber failure with the boundary that first observed it.
|
|
7
|
+
class Failure < StandardError
|
|
8
|
+
attr_reader :original, :boundary
|
|
9
|
+
|
|
10
|
+
def initialize(original, boundary)
|
|
11
|
+
@original = original
|
|
12
|
+
@boundary = boundary
|
|
13
|
+
super(original.message)
|
|
14
|
+
set_backtrace(original.backtrace)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Wraps one subscriber and unwraps only failures that it created.
|
|
19
|
+
class Boundary
|
|
20
|
+
def initialize(subscriber, passthrough: [])
|
|
21
|
+
@subscriber = subscriber
|
|
22
|
+
@passthrough = passthrough.freeze
|
|
23
|
+
@callable = method(:call).to_proc
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_proc = @callable
|
|
27
|
+
|
|
28
|
+
def call(event)
|
|
29
|
+
@subscriber.call(event)
|
|
30
|
+
rescue Failure
|
|
31
|
+
raise
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise if @passthrough.any? { |error_class| e.is_a?(error_class) }
|
|
34
|
+
|
|
35
|
+
raise Failure.new(e, self)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def unwrap(error)
|
|
39
|
+
error.boundary.equal?(self) ? error.original : error
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def wrap(subscriber, passthrough: [])
|
|
46
|
+
Boundary.new(subscriber, passthrough: passthrough) if subscriber
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unwrap(error, boundary)
|
|
50
|
+
return error unless error.is_a?(Failure) && boundary
|
|
51
|
+
|
|
52
|
+
boundary.unwrap(error)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def original(error)
|
|
56
|
+
error.is_a?(Failure) ? error.original : error
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_constant :EventDelivery
|
|
60
|
+
end
|
data/lib/mistri/locks.rb
CHANGED
|
@@ -6,15 +6,15 @@ module Mistri
|
|
|
6
6
|
# Cross-process coordination for hosts that run loops in more than one
|
|
7
7
|
# process: leases that say "this is alive right now" and flags that carry
|
|
8
8
|
# one-bit requests (stop) between processes. Everything expires on its
|
|
9
|
-
# own, so a crashed holder never wedges anything; a
|
|
10
|
-
# a heartbeat,
|
|
9
|
+
# own, so a crashed holder never wedges anything; a healthy holder renews
|
|
10
|
+
# on a heartbeat, while a stalled process can still outlive its lease.
|
|
11
11
|
#
|
|
12
12
|
# Configure once at boot:
|
|
13
13
|
#
|
|
14
14
|
# Mistri.locks = Mistri::Locks::Memory.new # single process
|
|
15
15
|
# Mistri.locks = Mistri::Locks::RailsCache.new # requires opt-in file
|
|
16
16
|
#
|
|
17
|
-
# An adapter implements
|
|
17
|
+
# An adapter implements seven methods: acquire(key, ttl:) -> bool,
|
|
18
18
|
# renew(key, ttl:), release(key), held?(key), set_flag(key, ttl:),
|
|
19
19
|
# flag?(key), clear_flag(key). With no adapter configured, everything
|
|
20
20
|
# lease-aware degrades gracefully: children read :running instead of
|
data/lib/mistri/mcp/client.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
3
|
module Mistri
|
|
6
4
|
module MCP
|
|
7
5
|
# A Model Context Protocol client: the initialize handshake, tools/list
|
|
@@ -20,37 +18,45 @@ module Mistri
|
|
|
20
18
|
# re-resolving, so a host's refresh logic lives in one lambda. A session
|
|
21
19
|
# the server expires (404 with a session attached) transparently
|
|
22
20
|
# re-initializes, per spec.
|
|
21
|
+
# Remote URLs default to public HTTPS. allow_non_public: is consulted only
|
|
22
|
+
# for otherwise blocked addresses and plain HTTP remains loopback-only.
|
|
23
|
+
# max_record_bytes bounds one JSON body, SSE line, or stdio record without
|
|
24
|
+
# imposing a lifetime limit on an SSE stream.
|
|
23
25
|
#
|
|
24
26
|
# One client serializes its calls; parallel tool calls against one
|
|
25
27
|
# server queue rather than interleave.
|
|
26
28
|
class Client
|
|
27
29
|
PROTOCOL_VERSION = "2025-11-25"
|
|
28
30
|
SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
|
|
29
|
-
LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
|
|
30
|
-
|
|
31
31
|
attr_reader :server_info
|
|
32
32
|
|
|
33
33
|
def initialize(url: nil, command: nil, env: {}, token: nil, headers: {},
|
|
34
|
-
client_name: "mistri", open_timeout: 15, read_timeout: 120
|
|
34
|
+
client_name: "mistri", open_timeout: 15, read_timeout: 120,
|
|
35
|
+
allow_non_public: nil, max_tool_pages: 100, max_tools: 10_000,
|
|
36
|
+
max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
|
|
35
37
|
if [url, command].compact.length != 1
|
|
36
38
|
raise ConfigurationError, "pass exactly one of url: or command:"
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
41
|
+
validate_limit(:max_tool_pages, max_tool_pages)
|
|
42
|
+
validate_limit(:max_tools, max_tools)
|
|
43
|
+
validate_limit(:max_record_bytes, max_record_bytes)
|
|
43
44
|
|
|
44
45
|
@wire = if url
|
|
45
46
|
Wires::Http.new(url: url, token: token, headers: headers,
|
|
46
|
-
open_timeout: open_timeout, read_timeout: read_timeout
|
|
47
|
+
open_timeout: open_timeout, read_timeout: read_timeout,
|
|
48
|
+
allow_non_public: allow_non_public,
|
|
49
|
+
max_record_bytes: max_record_bytes)
|
|
47
50
|
else
|
|
48
|
-
Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout
|
|
51
|
+
Wires::Stdio.new(command: command, env: env, read_timeout: read_timeout,
|
|
52
|
+
max_record_bytes: max_record_bytes)
|
|
49
53
|
end
|
|
50
54
|
@client_name = client_name
|
|
51
55
|
@mutex = Mutex.new
|
|
52
56
|
@serial = 0
|
|
53
57
|
@connected = false
|
|
58
|
+
@max_tool_pages = max_tool_pages
|
|
59
|
+
@max_tools = max_tools
|
|
54
60
|
end
|
|
55
61
|
|
|
56
62
|
# The server's tools as it describes them: hashes with "name",
|
|
@@ -72,8 +78,7 @@ module Mistri
|
|
|
72
78
|
end
|
|
73
79
|
|
|
74
80
|
def close
|
|
75
|
-
@
|
|
76
|
-
@connected = false
|
|
81
|
+
@mutex.synchronize { reset_wire }
|
|
77
82
|
nil
|
|
78
83
|
end
|
|
79
84
|
|
|
@@ -96,6 +101,9 @@ module Mistri
|
|
|
96
101
|
@server_info = result["serverInfo"]
|
|
97
102
|
@wire.notify({ jsonrpc: "2.0", method: "notifications/initialized" })
|
|
98
103
|
@connected = true
|
|
104
|
+
rescue Mistri::Error
|
|
105
|
+
reset_wire
|
|
106
|
+
raise
|
|
99
107
|
end
|
|
100
108
|
|
|
101
109
|
def request(method, params, reconnected: false, refreshed: false)
|
|
@@ -119,7 +127,9 @@ module Mistri
|
|
|
119
127
|
payload = { jsonrpc: "2.0", id: id, method: method, params: params }
|
|
120
128
|
result = nil
|
|
121
129
|
responded = false
|
|
122
|
-
|
|
130
|
+
replayable = method != "tools/call"
|
|
131
|
+
# A tool may commit its external side effect before the connection dies.
|
|
132
|
+
@wire.call(payload, replayable: replayable) do |record|
|
|
123
133
|
next unless record.is_a?(Hash) && record["id"] == id
|
|
124
134
|
|
|
125
135
|
responded = true
|
|
@@ -127,9 +137,28 @@ module Mistri
|
|
|
127
137
|
|
|
128
138
|
result = record["result"]
|
|
129
139
|
end
|
|
130
|
-
|
|
140
|
+
unless responded
|
|
141
|
+
detail = "the server sent no matching response to #{method}"
|
|
142
|
+
raise Error, detail if replayable
|
|
143
|
+
|
|
144
|
+
raise AmbiguousDeliveryError, "#{AmbiguousDeliveryError.default_message}: #{detail}"
|
|
145
|
+
end
|
|
131
146
|
|
|
132
147
|
result
|
|
148
|
+
rescue ResponseTooLargeError, ResponseTooComplexError, WireError => e
|
|
149
|
+
reset_wire
|
|
150
|
+
raise if replayable
|
|
151
|
+
return result if responded
|
|
152
|
+
|
|
153
|
+
message = "the request was sent but its tool outcome could not be confirmed: " \
|
|
154
|
+
"#{e.message}; the operation may have completed; do not retry automatically; " \
|
|
155
|
+
"verify external state first"
|
|
156
|
+
raise AmbiguousDeliveryError, message
|
|
157
|
+
rescue AmbiguousDeliveryError
|
|
158
|
+
reset_wire
|
|
159
|
+
return result if responded
|
|
160
|
+
|
|
161
|
+
raise
|
|
133
162
|
rescue ProviderError => e
|
|
134
163
|
raise SessionExpired if e.status == 404 && @wire.session?
|
|
135
164
|
|
|
@@ -139,13 +168,39 @@ module Mistri
|
|
|
139
168
|
def list_tools
|
|
140
169
|
collected = []
|
|
141
170
|
cursor = nil
|
|
142
|
-
|
|
171
|
+
seen_cursors = {}
|
|
172
|
+
@max_tool_pages.times do
|
|
143
173
|
result = request("tools/list", cursor ? { cursor: cursor } : {})
|
|
144
|
-
|
|
174
|
+
raise Error, "tools/list returned a malformed result" unless result.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
page = result["tools"]
|
|
177
|
+
raise Error, "tools/list returned a malformed tools collection" unless page.is_a?(Array)
|
|
178
|
+
if collected.length + page.length > @max_tools
|
|
179
|
+
raise Error, "tool discovery exceeded #{@max_tools} tools"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
collected.concat(page)
|
|
145
183
|
cursor = result["nextCursor"]
|
|
146
|
-
|
|
184
|
+
return collected if cursor.nil?
|
|
185
|
+
raise Error, "tools/list returned a malformed cursor" unless cursor.is_a?(String)
|
|
186
|
+
raise Error, "tools/list repeated a cursor" if seen_cursors[cursor]
|
|
187
|
+
|
|
188
|
+
seen_cursors[cursor] = true
|
|
147
189
|
end
|
|
148
|
-
|
|
190
|
+
raise Error, "tool discovery exceeded #{@max_tool_pages} pages"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_limit(name, value)
|
|
194
|
+
return if value.is_a?(Integer) && value.positive?
|
|
195
|
+
|
|
196
|
+
raise ConfigurationError, "#{name}: must be a positive integer"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def reset_wire
|
|
200
|
+
@wire.close
|
|
201
|
+
@wire.reset_session
|
|
202
|
+
@connected = false
|
|
203
|
+
@server_info = nil
|
|
149
204
|
end
|
|
150
205
|
|
|
151
206
|
def rpc_error(error)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Mistri
|
|
9
|
+
module MCP
|
|
10
|
+
# Resolves untrusted MCP URLs onto globally reachable addresses and pins
|
|
11
|
+
# that decision to the eventual connection. A host may narrowly approve a
|
|
12
|
+
# non-public address, but cannot bypass URL shape or plaintext rules.
|
|
13
|
+
module Egress
|
|
14
|
+
Target = Data.define(:uri, :address)
|
|
15
|
+
NAT64_WELL_KNOWN = IPAddr.new("64:ff9b::/96")
|
|
16
|
+
IPV6_GLOBAL_UNICAST = IPAddr.new("2000::/3")
|
|
17
|
+
|
|
18
|
+
# IANA IPv4/IPv6 Special-Purpose Address Registries, updated 2025-10-09.
|
|
19
|
+
# Broad non-global ranges contain a few globally reachable assignments.
|
|
20
|
+
# https://www.iana.org/assignments/iana-ipv4-special-registry/
|
|
21
|
+
# https://www.iana.org/assignments/iana-ipv6-special-registry/
|
|
22
|
+
# https://www.iana.org/assignments/ipv6-address-space/
|
|
23
|
+
GLOBAL_EXCEPTIONS = %w[
|
|
24
|
+
192.0.0.9/32
|
|
25
|
+
192.0.0.10/32
|
|
26
|
+
192.31.196.0/24
|
|
27
|
+
192.52.193.0/24
|
|
28
|
+
192.175.48.0/24
|
|
29
|
+
2001:1::1/128
|
|
30
|
+
2001:1::2/128
|
|
31
|
+
2001:1::3/128
|
|
32
|
+
2001:3::/32
|
|
33
|
+
2001:4:112::/48
|
|
34
|
+
2001:20::/28
|
|
35
|
+
2001:30::/28
|
|
36
|
+
2620:4f:8000::/48
|
|
37
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
38
|
+
|
|
39
|
+
NON_GLOBAL = %w[
|
|
40
|
+
0.0.0.0/8
|
|
41
|
+
10.0.0.0/8
|
|
42
|
+
100.64.0.0/10
|
|
43
|
+
127.0.0.0/8
|
|
44
|
+
169.254.0.0/16
|
|
45
|
+
172.16.0.0/12
|
|
46
|
+
192.0.0.0/24
|
|
47
|
+
192.0.2.0/24
|
|
48
|
+
192.88.99.0/24
|
|
49
|
+
192.168.0.0/16
|
|
50
|
+
198.18.0.0/15
|
|
51
|
+
198.51.100.0/24
|
|
52
|
+
203.0.113.0/24
|
|
53
|
+
224.0.0.0/4
|
|
54
|
+
240.0.0.0/4
|
|
55
|
+
::/96
|
|
56
|
+
64:ff9b:1::/48
|
|
57
|
+
100::/64
|
|
58
|
+
100:0:0:1::/64
|
|
59
|
+
2001::/23
|
|
60
|
+
2001:db8::/32
|
|
61
|
+
2002::/16
|
|
62
|
+
3fff::/20
|
|
63
|
+
5f00::/16
|
|
64
|
+
fc00::/7
|
|
65
|
+
fe80::/10
|
|
66
|
+
fec0::/10
|
|
67
|
+
ff00::/8
|
|
68
|
+
].map { |range| IPAddr.new(range) }.freeze
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
def target(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil)
|
|
73
|
+
targets(url, allow_non_public:, label:, timeout:, lookup:).first
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def targets(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil)
|
|
77
|
+
uri, address_resolver = resolver(url, allow_non_public:, label:, timeout:, lookup:)
|
|
78
|
+
address_resolver.call.map { |address| Target.new(uri:, address:) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resolver(url, allow_non_public: nil, label: "URL", timeout: 15, lookup: nil)
|
|
82
|
+
validate_exception(allow_non_public)
|
|
83
|
+
uri = normalize(url, label)
|
|
84
|
+
if uri.scheme == "http" && allow_non_public.nil?
|
|
85
|
+
raise UnsafeURLError, "#{label} must use HTTPS"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
snapshot = uri.to_s.freeze
|
|
89
|
+
resolve_addresses = lambda do
|
|
90
|
+
target = URI(snapshot)
|
|
91
|
+
addresses = resolve(target, label, timeout, lookup: lookup)
|
|
92
|
+
approved_addresses(target, addresses, allow_non_public:, label:)
|
|
93
|
+
.map(&:to_s).freeze
|
|
94
|
+
end
|
|
95
|
+
[URI(snapshot), resolve_addresses]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def approved_addresses(uri, addresses, allow_non_public: nil, label: "URL")
|
|
99
|
+
validate_exception(allow_non_public)
|
|
100
|
+
if addresses.empty?
|
|
101
|
+
raise UnsafeURLError,
|
|
102
|
+
"#{label} host #{uri.hostname.inspect} resolved to no addresses"
|
|
103
|
+
end
|
|
104
|
+
rejected = addresses.reject do |address|
|
|
105
|
+
policy_uri = URI(uri.to_s).freeze
|
|
106
|
+
policy_address = address.dup.freeze
|
|
107
|
+
globally_reachable?(address) || allow_non_public&.call(policy_uri, policy_address)
|
|
108
|
+
end
|
|
109
|
+
unless rejected.empty?
|
|
110
|
+
raise UnsafeURLError,
|
|
111
|
+
"#{label} host #{uri.hostname.inspect} resolves to a non-public address"
|
|
112
|
+
end
|
|
113
|
+
if uri.scheme == "http" && !addresses.all?(&:loopback?)
|
|
114
|
+
raise UnsafeURLError, "#{label} must use HTTPS"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
addresses
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def approved_address(uri, addresses, allow_non_public: nil, label: "URL")
|
|
121
|
+
approved_addresses(uri, addresses, allow_non_public:, label:).first
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def normalize(url, label = "URL")
|
|
125
|
+
uri = parse(url, label)
|
|
126
|
+
uri.scheme = uri.scheme.downcase
|
|
127
|
+
uri.host = uri.host.downcase
|
|
128
|
+
uri.port = nil if uri.port == uri.default_port
|
|
129
|
+
uri.path = "" if uri.path == "/"
|
|
130
|
+
uri
|
|
131
|
+
rescue URI::Error, ArgumentError
|
|
132
|
+
raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def redirect_uri(url)
|
|
136
|
+
value = url.to_s
|
|
137
|
+
uri = parse(value, "redirect_uri")
|
|
138
|
+
return value if uri.scheme == "https"
|
|
139
|
+
|
|
140
|
+
address = parse_address(uri.hostname)
|
|
141
|
+
return value if uri.hostname.casecmp?("localhost") || address&.loopback?
|
|
142
|
+
|
|
143
|
+
raise UnsafeURLError, "redirect_uri must use HTTPS or an explicit loopback address"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def origin(uri)
|
|
147
|
+
value = uri.dup
|
|
148
|
+
value.path = ""
|
|
149
|
+
value.query = nil
|
|
150
|
+
value.to_s
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def display(uri)
|
|
154
|
+
value = uri.dup
|
|
155
|
+
value.query = nil
|
|
156
|
+
value.to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def globally_reachable?(address)
|
|
160
|
+
address = address.native if address.ipv4_mapped?
|
|
161
|
+
if NAT64_WELL_KNOWN.include?(address)
|
|
162
|
+
embedded = IPAddr.new(address.to_i & 0xffffffff, Socket::AF_INET)
|
|
163
|
+
return globally_reachable?(embedded)
|
|
164
|
+
end
|
|
165
|
+
return true if GLOBAL_EXCEPTIONS.any? { |range| range.include?(address) }
|
|
166
|
+
return false if address.ipv6? && !IPV6_GLOBAL_UNICAST.include?(address)
|
|
167
|
+
|
|
168
|
+
NON_GLOBAL.none? { |range| range.include?(address) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def resolve(uri, label, timeout, lookup: nil)
|
|
172
|
+
lookup ||= Addrinfo.method(:getaddrinfo)
|
|
173
|
+
entries = lookup.call(uri.hostname, uri.port, nil, :STREAM, timeout: timeout)
|
|
174
|
+
found = entries.filter_map do |entry|
|
|
175
|
+
address = parse_address(entry.ip_address)
|
|
176
|
+
address&.ipv4_mapped? ? address.native : address
|
|
177
|
+
end.uniq
|
|
178
|
+
if found.empty?
|
|
179
|
+
raise UnsafeURLError,
|
|
180
|
+
"#{label} host #{uri.hostname.inspect} resolved to no addresses"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
found
|
|
184
|
+
rescue SocketError, IOError, Timeout::Error
|
|
185
|
+
raise UnsafeURLError, "#{label} host #{uri.hostname.inspect} could not be resolved"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_address(value)
|
|
189
|
+
IPAddr.new(value)
|
|
190
|
+
rescue IPAddr::Error
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_exception(exception)
|
|
195
|
+
return if exception.nil? || exception.respond_to?(:call)
|
|
196
|
+
|
|
197
|
+
raise ConfigurationError, "allow_non_public: must be callable"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def parse(url, label)
|
|
201
|
+
uri = URI.parse(url.to_s)
|
|
202
|
+
unless uri.is_a?(URI::HTTP) && %w[http https].include?(uri.scheme) &&
|
|
203
|
+
!uri.hostname.to_s.empty?
|
|
204
|
+
raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL"
|
|
205
|
+
end
|
|
206
|
+
raise UnsafeURLError, "#{label} must not contain credentials" if uri.user || uri.password
|
|
207
|
+
raise UnsafeURLError, "#{label} must not contain a fragment" if uri.fragment
|
|
208
|
+
raise UnsafeURLError, "#{label} has an invalid port" unless (1..65_535).cover?(uri.port)
|
|
209
|
+
|
|
210
|
+
uri
|
|
211
|
+
rescue URI::Error, ArgumentError
|
|
212
|
+
raise UnsafeURLError, "#{label} must be an absolute HTTP(S) URL"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|