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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. 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