mistri 0.4.1 → 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 +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- 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/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- 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 +43 -9
- 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 +36 -6
- 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 +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -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 +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- 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 +17 -1
- data/mistri.gemspec +34 -0
- metadata +38 -3
|
@@ -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
|