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/schema.rb
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "tool_arguments"
|
|
7
|
+
|
|
3
8
|
module Mistri
|
|
4
|
-
#
|
|
5
|
-
# in Ruby instead of hand-writing JSON Schema. It emits the object schema
|
|
6
|
-
# every provider accepts:
|
|
7
|
-
#
|
|
8
|
-
# Schema.build do
|
|
9
|
-
# string :city, "City name", required: true
|
|
10
|
-
# string :units, "Temperature units", enum: %w[celsius fahrenheit]
|
|
11
|
-
# integer :days, "Forecast length"
|
|
12
|
-
# end
|
|
13
|
-
#
|
|
14
|
-
# A raw JSON Schema hash is always accepted directly for anything the
|
|
15
|
-
# builder does not cover, so the DSL is a convenience, never a ceiling.
|
|
9
|
+
# Builds tool schemas and validates the portable JSON Schema subset Mistri owns.
|
|
16
10
|
class Schema
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
11
|
+
DIALECT = "https://json-schema.org/draft/2020-12/schema"
|
|
12
|
+
MAX_VIOLATIONS = 8
|
|
13
|
+
MAX_DEPTH = 64
|
|
14
|
+
MAX_NODES = 10_000
|
|
15
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
16
|
+
JSON_TYPES = %w[object array string integer number boolean null].freeze
|
|
17
|
+
|
|
18
|
+
# instance_exec binds self to the builder without passing an argument, so
|
|
19
|
+
# zero-arity lambdas and procs behave the same way.
|
|
20
|
+
def self.build(&block)
|
|
21
|
+
raise ConfigurationError, "schema needs a block" unless block
|
|
22
|
+
|
|
20
23
|
builder = new
|
|
21
|
-
builder.instance_exec(&)
|
|
24
|
+
builder.instance_exec(&block)
|
|
22
25
|
builder.to_h
|
|
23
26
|
end
|
|
24
27
|
|
|
@@ -46,8 +49,8 @@ module Mistri
|
|
|
46
49
|
nil
|
|
47
50
|
end
|
|
48
51
|
|
|
49
|
-
def object(name, description = nil, required: false, &)
|
|
50
|
-
prop = self.class.build(&)
|
|
52
|
+
def object(name, description = nil, required: false, &block)
|
|
53
|
+
prop = block ? self.class.build(&block) : self.class.new.to_h
|
|
51
54
|
prop[:description] = description if description
|
|
52
55
|
@properties[name.to_s] = prop
|
|
53
56
|
@required << name.to_s if required
|
|
@@ -61,102 +64,917 @@ module Mistri
|
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
class << self
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
# Definition checks cover the complete JSON document. Unsupported
|
|
68
|
+
# assertion keywords remain provider guidance in this general API.
|
|
69
|
+
def validate_definition!(schema)
|
|
70
|
+
Compiled.new(schema)
|
|
71
|
+
schema
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns bounded, model-readable failures for the subset Mistri owns:
|
|
75
|
+
# type, enum, required, properties, additionalProperties, items, and
|
|
76
|
+
# prefixItems. The input is first normalized to the JSON value providers see.
|
|
69
77
|
def violations(value, schema, path = "$")
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return [mismatch] if mismatch
|
|
78
|
+
Compiled.new(schema).violations(value, path)
|
|
79
|
+
end
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
# A Tool keeps this plan for its lifetime. complete is explicit authority
|
|
82
|
+
# for a host validator to own schema interactions core cannot represent.
|
|
83
|
+
def tool_validator(schema, complete: false)
|
|
84
|
+
Compiled.new(schema, tool: true, complete: complete)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# MCP schemas get the same stance as host-authored tools: directly
|
|
88
|
+
# reachable portable constraints are enforced locally, while unsupported
|
|
89
|
+
# applicator subtrees stay server guidance. Complete validators own the
|
|
90
|
+
# whole local contract; external references are rejected in either mode.
|
|
91
|
+
def validate_mcp!(schema, complete: false)
|
|
92
|
+
compiled = Compiled.new(schema, tool: true, complete: complete)
|
|
93
|
+
AssertionContract.new(complete:, context: "MCP input schema",
|
|
94
|
+
allow_guidance: true).call(compiled.schema)
|
|
95
|
+
compiled.schema
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Paths to standard assertions that the zero-dependency validator does
|
|
99
|
+
# not implement. An empty list means local validation owns the contract.
|
|
100
|
+
def unsupported_assertions(schema)
|
|
101
|
+
compiled = Compiled.new(schema)
|
|
102
|
+
AssertionScanner.new.call(compiled.schema).map(&:first).freeze
|
|
83
103
|
end
|
|
84
104
|
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
105
|
+
# Task mode validates only schemas whose complete assertion contract is
|
|
106
|
+
# implemented locally; provider constrained decoding is an optimization.
|
|
107
|
+
def task_violations(value, schema, path = "$")
|
|
108
|
+
task_plan(schema).violations(value, path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# One task plan owns the strict prompt and validation schema. Providers
|
|
112
|
+
# may derive a compatible native constraint, but local validation remains
|
|
113
|
+
# the guarantee when their structured-output subset is narrower.
|
|
114
|
+
def task_plan(schema, all_required: false)
|
|
115
|
+
source = compile_task_schema(schema)
|
|
116
|
+
strict = strict_at(source.schema, all_required:, path: "$")
|
|
117
|
+
TaskPlan.new(Compiled.new(strict))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Prepares a schema for constrained decoding without turning a freeform
|
|
121
|
+
# object into one that accepts only {}. Definition compilation happens
|
|
122
|
+
# first so recursive transformation never sees a cyclic or hostile graph.
|
|
89
123
|
def strict(schema, all_required: false)
|
|
124
|
+
source = Compiled.new(schema)
|
|
125
|
+
strict_at(source.schema, all_required:, path: "$")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def compile_task_schema(schema)
|
|
131
|
+
compiled = Compiled.new(schema)
|
|
132
|
+
AssertionContract.new(complete: false, context: "task output schema").call(
|
|
133
|
+
compiled.schema
|
|
134
|
+
)
|
|
135
|
+
compiled
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def strict_at(schema, all_required:, path:)
|
|
139
|
+
return schema if schema.equal?(true) || schema.equal?(false)
|
|
140
|
+
|
|
90
141
|
spec = schema.transform_keys(&:to_s)
|
|
91
142
|
out = spec.dup
|
|
92
143
|
if spec["type"] == "object" || spec.key?("properties")
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
144
|
+
raw_props = spec["properties"] || {}
|
|
145
|
+
reject_open_object!(spec, raw_props, path)
|
|
146
|
+
props = raw_props.to_h do |key, member|
|
|
147
|
+
member_path = "#{path}.#{key}"
|
|
148
|
+
[key.to_s, strict_at(member, all_required: all_required, path: member_path)]
|
|
149
|
+
end.freeze
|
|
96
150
|
out["properties"] = props
|
|
97
151
|
out["additionalProperties"] = false
|
|
98
|
-
|
|
152
|
+
required = all_required ? props.keys : Array(spec["required"]).map(&:to_s)
|
|
153
|
+
out["required"] = required.freeze
|
|
99
154
|
end
|
|
100
|
-
if spec["items"]
|
|
101
|
-
out["items"] =
|
|
102
|
-
|
|
155
|
+
if schema_value?(spec["items"])
|
|
156
|
+
out["items"] = strict_at(
|
|
157
|
+
spec["items"], all_required: all_required, path: "#{path}[]"
|
|
158
|
+
)
|
|
103
159
|
end
|
|
104
|
-
out
|
|
160
|
+
strict_prefix_items(spec, out, all_required, path)
|
|
161
|
+
out.freeze
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def strict_prefix_items(spec, out, all_required, path)
|
|
165
|
+
return unless spec["prefixItems"].is_a?(Array)
|
|
166
|
+
|
|
167
|
+
out["prefixItems"] = spec["prefixItems"].each_with_index.map do |member, index|
|
|
168
|
+
strict_at(member, all_required: all_required, path: "#{path}[#{index}]")
|
|
169
|
+
end.freeze
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def schema_value?(value)
|
|
173
|
+
value.is_a?(Hash) || value.equal?(true) || value.equal?(false)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def reject_open_object!(spec, properties, path)
|
|
177
|
+
if spec.key?("additionalProperties") && spec["additionalProperties"] != false
|
|
178
|
+
raise ConfigurationError,
|
|
179
|
+
"#{path} is open and cannot be represented by a strict schema"
|
|
180
|
+
end
|
|
181
|
+
return unless properties.empty? && spec["additionalProperties"] != false
|
|
182
|
+
|
|
183
|
+
raise ConfigurationError,
|
|
184
|
+
"#{path} is freeform and cannot be represented by a strict schema"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Shared JSON semantics keep definition and instance handling identical.
|
|
189
|
+
module ValidationSupport
|
|
190
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
191
|
+
MAX_KEY_BYTES = 64
|
|
192
|
+
MAX_PATH_BYTES = 240
|
|
193
|
+
MAX_SEGMENT_BYTES = 96
|
|
194
|
+
|
|
195
|
+
module_function
|
|
196
|
+
|
|
197
|
+
def root_path(path)
|
|
198
|
+
bounded_utf8(path.to_s, MAX_PATH_BYTES).gsub(/[[:cntrl:]]/, "?")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def child_path(path, key)
|
|
202
|
+
key_text = bounded_utf8(key, MAX_KEY_BYTES)
|
|
203
|
+
segment = key_text.match?(IDENTIFIER) ? ".#{key_text}" : "[#{JSON.generate(key_text)}]"
|
|
204
|
+
segment = "[\"...\"]" if segment.bytesize > MAX_SEGMENT_BYTES
|
|
205
|
+
joined = "#{path}#{segment}"
|
|
206
|
+
joined.bytesize <= MAX_PATH_BYTES ? joined : "$...#{segment}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def index_path(path, index)
|
|
210
|
+
segment = "[#{index}]"
|
|
211
|
+
joined = "#{path}#{segment}"
|
|
212
|
+
joined.bytesize <= MAX_PATH_BYTES ? joined : "$...#{segment}"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def item_path(path) = "#{path}[]"
|
|
216
|
+
|
|
217
|
+
def json_number?(value)
|
|
218
|
+
value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def json_integer?(value)
|
|
222
|
+
value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value.modulo(1).zero?)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def json_type(value)
|
|
226
|
+
case value
|
|
227
|
+
when Hash then "object"
|
|
228
|
+
when Array then "array"
|
|
229
|
+
when String then "string"
|
|
230
|
+
when Integer then "integer"
|
|
231
|
+
when Float then "number"
|
|
232
|
+
when true, false then "boolean"
|
|
233
|
+
when nil then "null"
|
|
234
|
+
else "non-JSON value"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def bounded_utf8(value, max_bytes)
|
|
239
|
+
source = value.to_s
|
|
240
|
+
truncated = source.bytesize > max_bytes
|
|
241
|
+
source = source.byteslice(0, max_bytes) if truncated
|
|
242
|
+
text = source.encode(Encoding::UTF_8, invalid: :replace,
|
|
243
|
+
undef: :replace, replace: "?")
|
|
244
|
+
return text if !truncated && text.bytesize <= max_bytes
|
|
245
|
+
|
|
246
|
+
prefix = text.byteslice(0, max_bytes - 3).dup.force_encoding(Encoding::UTF_8).scrub
|
|
247
|
+
"#{prefix}..."
|
|
248
|
+
end
|
|
249
|
+
private_class_method :bounded_utf8
|
|
250
|
+
end
|
|
251
|
+
private_constant :ValidationSupport
|
|
252
|
+
|
|
253
|
+
# Canonicalization creates the exact immutable JSON document provider
|
|
254
|
+
# serializers receive, preventing encoding and post-definition mutation drift.
|
|
255
|
+
class DefinitionCompiler
|
|
256
|
+
def initialize
|
|
257
|
+
@nodes = 0
|
|
258
|
+
@bytes = 0
|
|
259
|
+
@active = {}.compare_by_identity
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def call(schema)
|
|
263
|
+
owned = copy(schema, "$", 0)
|
|
264
|
+
unless ToolArguments.serialized_size_within_limit?(owned, limit: MAX_BYTES)
|
|
265
|
+
configuration!("$", "exceeds the schema byte limit")
|
|
266
|
+
end
|
|
267
|
+
DefinitionValidator.new.call(owned)
|
|
268
|
+
owned
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
private
|
|
272
|
+
|
|
273
|
+
def copy(value, path, depth)
|
|
274
|
+
count!(path, depth)
|
|
275
|
+
case value
|
|
276
|
+
when Hash then copy_hash(value, path, depth)
|
|
277
|
+
when Array then copy_array(value, path, depth)
|
|
278
|
+
when String then copy_string(value, path)
|
|
279
|
+
when Symbol then copy_string(value.to_s, path)
|
|
280
|
+
when Integer, true, false, nil then value
|
|
281
|
+
when Float
|
|
282
|
+
configuration!(path, "must contain only finite JSON numbers") unless value.finite?
|
|
283
|
+
value
|
|
284
|
+
else configuration!(path, "must contain only JSON values")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def copy_hash(value, path, depth)
|
|
289
|
+
within(value, path) do
|
|
290
|
+
value.each_with_object({}) do |(key, member), out|
|
|
291
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
292
|
+
configuration!(path, "must use string or symbol keys")
|
|
293
|
+
end
|
|
294
|
+
name = copy_string(key.to_s, path)
|
|
295
|
+
configuration!(path, "contains duplicate JSON keys") if out.key?(name)
|
|
296
|
+
out[name] = copy(member, ValidationSupport.child_path(path, name), depth + 1)
|
|
297
|
+
end.freeze
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def copy_array(value, path, depth)
|
|
302
|
+
within(value, path) do
|
|
303
|
+
value.each_with_index.map do |member, index|
|
|
304
|
+
copy(member, ValidationSupport.index_path(path, index), depth + 1)
|
|
305
|
+
end.freeze
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def copy_string(value, path)
|
|
310
|
+
if value.encoding == Encoding::UTF_8
|
|
311
|
+
configuration!(path, "must use valid UTF-8 strings") unless value.valid_encoding?
|
|
312
|
+
string = value.dup
|
|
313
|
+
else
|
|
314
|
+
string = value.encode(Encoding::UTF_8)
|
|
315
|
+
end
|
|
316
|
+
@bytes += string.bytesize
|
|
317
|
+
configuration!(path, "exceeds the schema byte limit") if @bytes > MAX_BYTES
|
|
318
|
+
string.freeze
|
|
319
|
+
rescue EncodingError
|
|
320
|
+
configuration!(path, "must use valid UTF-8 strings")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def within(value, path)
|
|
324
|
+
configuration!(path, "contains a cycle") if @active[value]
|
|
325
|
+
|
|
326
|
+
@active[value] = true
|
|
327
|
+
inserted = true
|
|
328
|
+
yield
|
|
329
|
+
ensure
|
|
330
|
+
@active.delete(value) if inserted
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def count!(path, depth)
|
|
334
|
+
configuration!(path, "exceeds the schema depth limit") if depth > MAX_DEPTH
|
|
335
|
+
@nodes += 1
|
|
336
|
+
configuration!(path, "exceeds the schema size limit") if @nodes > MAX_NODES
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def configuration!(path, problem)
|
|
340
|
+
raise ConfigurationError, "#{path} #{problem}"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
private_constant :DefinitionCompiler
|
|
344
|
+
|
|
345
|
+
# Keyword shape checks are distinct from the full-document JSON scan so
|
|
346
|
+
# ignored extensions cannot evade ownership and resource limits.
|
|
347
|
+
class DefinitionValidator
|
|
348
|
+
STRING_KEYWORDS = %w[
|
|
349
|
+
title description pattern format contentEncoding contentMediaType $comment
|
|
350
|
+
].freeze
|
|
351
|
+
BOOLEAN_KEYWORDS = %w[uniqueItems deprecated readOnly writeOnly].freeze
|
|
352
|
+
NUMBER_KEYWORDS = %w[maximum exclusiveMaximum minimum exclusiveMinimum].freeze
|
|
353
|
+
NONNEGATIVE_INTEGER_KEYWORDS = %w[
|
|
354
|
+
maxLength minLength maxItems minItems maxContains minContains
|
|
355
|
+
maxProperties minProperties
|
|
356
|
+
].freeze
|
|
357
|
+
URI_KEYWORDS = %w[$id $ref $dynamicRef].freeze
|
|
358
|
+
ANCHOR = /\A[A-Za-z_][-A-Za-z0-9._]*\z/
|
|
359
|
+
SCHEMA_MAPS = %w[$defs definitions dependentSchemas].freeze
|
|
360
|
+
SCHEMA_ARRAYS = %w[allOf anyOf oneOf].freeze
|
|
361
|
+
SCHEMA_VALUES = %w[
|
|
362
|
+
not if then else contains propertyNames unevaluatedProperties
|
|
363
|
+
unevaluatedItems contentSchema
|
|
364
|
+
].freeze
|
|
365
|
+
|
|
366
|
+
def call(schema)
|
|
367
|
+
walk_schema(schema, "$")
|
|
368
|
+
schema
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
private
|
|
372
|
+
|
|
373
|
+
def walk_schema(schema, path)
|
|
374
|
+
return if schema.equal?(true) || schema.equal?(false)
|
|
375
|
+
|
|
376
|
+
configuration!(path, "must be a schema object or boolean") unless schema.is_a?(Hash)
|
|
377
|
+
validate_dialect(schema, path)
|
|
378
|
+
validate_core_keywords(schema, path)
|
|
379
|
+
validate_annotation_keywords(schema, path)
|
|
380
|
+
validate_assertion_shapes(schema, path)
|
|
381
|
+
validate_type(schema, path)
|
|
382
|
+
validate_enum(schema, path)
|
|
383
|
+
validate_required(schema, path)
|
|
384
|
+
validate_schema_map(schema, "properties", path)
|
|
385
|
+
validate_schema_map(schema, "patternProperties", path)
|
|
386
|
+
validate_items(schema, path)
|
|
387
|
+
validate_prefix_items(schema, path)
|
|
388
|
+
validate_additional_properties(schema, path)
|
|
389
|
+
validate_dependent_required(schema, path)
|
|
390
|
+
validate_dependencies(schema, path)
|
|
391
|
+
SCHEMA_MAPS.each { |keyword| validate_schema_map(schema, keyword, path) }
|
|
392
|
+
SCHEMA_ARRAYS.each { |keyword| validate_schema_array(schema, keyword, path) }
|
|
393
|
+
SCHEMA_VALUES.each { |keyword| validate_schema_value(schema, keyword, path) }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def validate_dialect(spec, path)
|
|
397
|
+
return unless spec.key?("$schema")
|
|
398
|
+
return if spec["$schema"] == DIALECT
|
|
399
|
+
|
|
400
|
+
configuration!("#{path}.$schema", "must declare JSON Schema 2020-12")
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def validate_core_keywords(spec, path)
|
|
404
|
+
validate_uri_keywords(spec, path)
|
|
405
|
+
validate_anchor_keywords(spec, path)
|
|
406
|
+
validate_vocabulary(spec, path)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def validate_uri_keywords(spec, path)
|
|
410
|
+
URI_KEYWORDS.each do |keyword|
|
|
411
|
+
next unless spec.key?(keyword)
|
|
412
|
+
|
|
413
|
+
uri = validate_uri_reference(spec[keyword], "#{path}.#{keyword}")
|
|
414
|
+
next unless keyword == "$id" && uri.fragment && !uri.fragment.empty?
|
|
415
|
+
|
|
416
|
+
configuration!("#{path}.$id", "must not contain a non-empty fragment")
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def validate_anchor_keywords(spec, path)
|
|
421
|
+
%w[$anchor $dynamicAnchor].each do |keyword|
|
|
422
|
+
next unless spec.key?(keyword)
|
|
423
|
+
next if spec[keyword].is_a?(String) && spec[keyword].match?(ANCHOR)
|
|
424
|
+
|
|
425
|
+
configuration!("#{path}.#{keyword}", "must be a valid plain-name anchor")
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def validate_vocabulary(spec, path)
|
|
430
|
+
return unless spec.key?("$vocabulary")
|
|
431
|
+
|
|
432
|
+
vocabulary = spec["$vocabulary"]
|
|
433
|
+
unless vocabulary.is_a?(Hash) && vocabulary.values.all? do |required|
|
|
434
|
+
required.equal?(true) || required.equal?(false)
|
|
435
|
+
end
|
|
436
|
+
configuration!("#{path}.$vocabulary", "must map URI strings to booleans")
|
|
437
|
+
end
|
|
438
|
+
vocabulary.each_key do |uri|
|
|
439
|
+
validate_absolute_normalized_uri(uri, "#{path}.$vocabulary")
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def validate_annotation_keywords(spec, path)
|
|
444
|
+
STRING_KEYWORDS.each do |keyword|
|
|
445
|
+
next unless spec.key?(keyword)
|
|
446
|
+
next if spec[keyword].is_a?(String)
|
|
447
|
+
|
|
448
|
+
configuration!("#{path}.#{keyword}", "must be a string")
|
|
449
|
+
end
|
|
450
|
+
BOOLEAN_KEYWORDS.each do |keyword|
|
|
451
|
+
next unless spec.key?(keyword)
|
|
452
|
+
next if spec[keyword].equal?(true) || spec[keyword].equal?(false)
|
|
453
|
+
|
|
454
|
+
configuration!("#{path}.#{keyword}", "must be a boolean")
|
|
455
|
+
end
|
|
456
|
+
return unless spec.key?("examples")
|
|
457
|
+
return if spec["examples"].is_a?(Array)
|
|
458
|
+
|
|
459
|
+
configuration!("#{path}.examples", "must be an array")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def validate_assertion_shapes(spec, path)
|
|
463
|
+
NUMBER_KEYWORDS.each do |keyword|
|
|
464
|
+
next unless spec.key?(keyword)
|
|
465
|
+
next if ValidationSupport.json_number?(spec[keyword])
|
|
466
|
+
|
|
467
|
+
configuration!("#{path}.#{keyword}", "must be a number")
|
|
468
|
+
end
|
|
469
|
+
NONNEGATIVE_INTEGER_KEYWORDS.each do |keyword|
|
|
470
|
+
next unless spec.key?(keyword)
|
|
471
|
+
|
|
472
|
+
value = spec[keyword]
|
|
473
|
+
next if ValidationSupport.json_integer?(value) && value >= 0
|
|
474
|
+
|
|
475
|
+
configuration!("#{path}.#{keyword}", "must be a non-negative integer")
|
|
476
|
+
end
|
|
477
|
+
return unless spec.key?("multipleOf")
|
|
478
|
+
return if ValidationSupport.json_number?(spec["multipleOf"]) && spec["multipleOf"].positive?
|
|
479
|
+
|
|
480
|
+
configuration!("#{path}.multipleOf", "must be a number greater than zero")
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def validate_uri_reference(value, path)
|
|
484
|
+
configuration!(path, "must be a URI-reference string") unless value.is_a?(String)
|
|
485
|
+
|
|
486
|
+
URI.parse(value)
|
|
487
|
+
rescue URI::InvalidURIError
|
|
488
|
+
configuration!(path, "must be a valid URI-reference")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def validate_absolute_normalized_uri(value, path)
|
|
492
|
+
configuration!(path, "must use absolute normalized URI keys") unless value.is_a?(String)
|
|
493
|
+
|
|
494
|
+
uri = URI.parse(value)
|
|
495
|
+
return uri if uri.absolute? && uri.normalize.to_s == value
|
|
496
|
+
|
|
497
|
+
configuration!(path, "must use absolute normalized URI keys")
|
|
498
|
+
rescue URI::InvalidURIError
|
|
499
|
+
configuration!(path, "must use absolute normalized URI keys")
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def validate_type(spec, path)
|
|
503
|
+
return unless spec.key?("type")
|
|
504
|
+
|
|
505
|
+
raw = spec["type"]
|
|
506
|
+
names = raw.is_a?(Array) ? raw : [raw]
|
|
507
|
+
valid = !names.empty? && names.all?(String)
|
|
508
|
+
configuration!("#{path}.type", "must be a primitive or non-empty union") unless valid
|
|
509
|
+
unknown = names - JSON_TYPES
|
|
510
|
+
configuration!("#{path}.type", "contains an unknown JSON type") unless unknown.empty?
|
|
511
|
+
configuration!("#{path}.type", "must not repeat a JSON type") if names.uniq != names
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def validate_enum(spec, path)
|
|
515
|
+
return unless spec.key?("enum")
|
|
516
|
+
|
|
517
|
+
values = spec["enum"]
|
|
518
|
+
configuration!("#{path}.enum", "must be an array") unless values.is_a?(Array)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def validate_required(spec, path)
|
|
522
|
+
return unless spec.key?("required")
|
|
523
|
+
|
|
524
|
+
keys = spec["required"]
|
|
525
|
+
unless keys.is_a?(Array) && keys.all?(String)
|
|
526
|
+
configuration!("#{path}.required", "must be an array of unique strings")
|
|
527
|
+
end
|
|
528
|
+
return if keys.uniq == keys
|
|
529
|
+
|
|
530
|
+
configuration!("#{path}.required", "must be an array of unique strings")
|
|
105
531
|
end
|
|
106
532
|
|
|
533
|
+
def validate_dependent_required(spec, path)
|
|
534
|
+
return unless spec.key?("dependentRequired")
|
|
535
|
+
|
|
536
|
+
dependencies = spec["dependentRequired"]
|
|
537
|
+
unless dependencies.is_a?(Hash)
|
|
538
|
+
configuration!("#{path}.dependentRequired", "must be an object")
|
|
539
|
+
end
|
|
540
|
+
dependencies.each do |key, members|
|
|
541
|
+
next if members.is_a?(Array) && members.all?(String) && members.uniq == members
|
|
542
|
+
|
|
543
|
+
member_path = ValidationSupport.child_path("#{path}.dependentRequired", key)
|
|
544
|
+
configuration!(member_path, "must be an array of unique strings")
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def validate_dependencies(spec, path)
|
|
549
|
+
return unless spec.key?("dependencies")
|
|
550
|
+
|
|
551
|
+
dependencies = spec["dependencies"]
|
|
552
|
+
configuration!("#{path}.dependencies", "must be an object") unless dependencies.is_a?(Hash)
|
|
553
|
+
dependencies.each do |key, member|
|
|
554
|
+
member_path = ValidationSupport.child_path("#{path}.dependencies", key)
|
|
555
|
+
if member.is_a?(Array)
|
|
556
|
+
valid = member.all?(String) && member.uniq == member
|
|
557
|
+
configuration!(member_path, "must be a schema or array of unique strings") unless valid
|
|
558
|
+
else
|
|
559
|
+
walk_schema(member, member_path)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def validate_schema_map(spec, keyword, path)
|
|
565
|
+
return unless spec.key?(keyword)
|
|
566
|
+
|
|
567
|
+
members = spec[keyword]
|
|
568
|
+
configuration!("#{path}.#{keyword}", "must be an object") unless members.is_a?(Hash)
|
|
569
|
+
members.each do |key, member|
|
|
570
|
+
walk_schema(member, ValidationSupport.child_path("#{path}.#{keyword}", key))
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def validate_schema_array(spec, keyword, path)
|
|
575
|
+
return unless spec.key?(keyword)
|
|
576
|
+
|
|
577
|
+
members = spec[keyword]
|
|
578
|
+
unless members.is_a?(Array) && !members.empty?
|
|
579
|
+
configuration!("#{path}.#{keyword}", "must be a non-empty array of schemas")
|
|
580
|
+
end
|
|
581
|
+
members.each_with_index do |member, index|
|
|
582
|
+
walk_schema(member, ValidationSupport.index_path("#{path}.#{keyword}", index))
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def validate_schema_value(spec, keyword, path)
|
|
587
|
+
return unless spec.key?(keyword)
|
|
588
|
+
|
|
589
|
+
walk_schema(spec[keyword], "#{path}.#{keyword}")
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def validate_items(spec, path)
|
|
593
|
+
return unless spec.key?("items")
|
|
594
|
+
|
|
595
|
+
items = spec["items"]
|
|
596
|
+
if items.is_a?(Array)
|
|
597
|
+
configuration!("#{path}.items",
|
|
598
|
+
"must be a schema in JSON Schema 2020-12; use prefixItems for tuples")
|
|
599
|
+
end
|
|
600
|
+
walk_schema(items, ValidationSupport.item_path(path))
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def validate_prefix_items(spec, path)
|
|
604
|
+
return unless spec.key?("prefixItems")
|
|
605
|
+
|
|
606
|
+
members = spec["prefixItems"]
|
|
607
|
+
unless members.is_a?(Array) && !members.empty?
|
|
608
|
+
configuration!("#{path}.prefixItems", "must be a non-empty array of schemas")
|
|
609
|
+
end
|
|
610
|
+
members.each_with_index do |member, index|
|
|
611
|
+
walk_schema(member, ValidationSupport.index_path("#{path}.prefixItems", index))
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def validate_additional_properties(spec, path)
|
|
616
|
+
return unless spec.key?("additionalProperties")
|
|
617
|
+
|
|
618
|
+
walk_schema(spec["additionalProperties"], "#{path}.additionalProperties")
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def configuration!(path, problem)
|
|
622
|
+
raise ConfigurationError, "#{path} #{problem}"
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
private_constant :DefinitionValidator
|
|
626
|
+
|
|
627
|
+
# Finds standard assertions that the portable validator intentionally
|
|
628
|
+
# leaves to a complete JSON Schema implementation.
|
|
629
|
+
class AssertionScanner
|
|
630
|
+
ASSERTIONS = %w[
|
|
631
|
+
$ref $dynamicRef allOf anyOf oneOf not if then else
|
|
632
|
+
patternProperties dependentSchemas dependentRequired dependencies
|
|
633
|
+
propertyNames unevaluatedProperties unevaluatedItems contains
|
|
634
|
+
multipleOf maximum exclusiveMaximum minimum exclusiveMinimum
|
|
635
|
+
maxLength minLength pattern maxItems minItems uniqueItems
|
|
636
|
+
maxContains minContains maxProperties minProperties const
|
|
637
|
+
].freeze
|
|
638
|
+
SCHEMA_MAPS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
|
|
639
|
+
SCHEMA_ARRAYS = %w[allOf anyOf oneOf prefixItems].freeze
|
|
640
|
+
SCHEMA_VALUES = %w[
|
|
641
|
+
not if then else contains propertyNames unevaluatedProperties
|
|
642
|
+
unevaluatedItems additionalProperties items
|
|
643
|
+
].freeze
|
|
644
|
+
REFERENCE_SCHEMA_VALUES = [*SCHEMA_VALUES, "contentSchema"].freeze
|
|
645
|
+
|
|
646
|
+
def call(schema, references_only: false)
|
|
647
|
+
@findings = []
|
|
648
|
+
@references_only = references_only
|
|
649
|
+
walk(schema, "$")
|
|
650
|
+
@findings.freeze
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
private
|
|
654
|
+
|
|
655
|
+
def walk(schema, path)
|
|
656
|
+
return if schema.equal?(true) || schema.equal?(false)
|
|
657
|
+
|
|
658
|
+
record_assertions(schema, path)
|
|
659
|
+
walk_maps(schema, path)
|
|
660
|
+
walk_arrays(schema, path)
|
|
661
|
+
walk_values(schema, path)
|
|
662
|
+
walk_dependencies(schema, path)
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def record_assertions(schema, path)
|
|
666
|
+
keywords = @references_only ? %w[$ref $dynamicRef] : ASSERTIONS
|
|
667
|
+
keywords.each do |keyword|
|
|
668
|
+
next unless schema.key?(keyword)
|
|
669
|
+
next if ignorable_empty_map?(schema, keyword)
|
|
670
|
+
|
|
671
|
+
@findings << ["#{path}.#{keyword}".freeze, keyword, schema[keyword]].freeze
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def ignorable_empty_map?(schema, keyword)
|
|
676
|
+
return false unless %w[
|
|
677
|
+
patternProperties dependentSchemas dependentRequired dependencies
|
|
678
|
+
].include?(keyword)
|
|
679
|
+
|
|
680
|
+
schema[keyword].is_a?(Hash) && schema[keyword].empty?
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def walk_maps(schema, path)
|
|
684
|
+
SCHEMA_MAPS.each do |keyword|
|
|
685
|
+
schema.fetch(keyword, {}).each do |key, member|
|
|
686
|
+
walk(member, ValidationSupport.child_path("#{path}.#{keyword}", key))
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def walk_arrays(schema, path)
|
|
692
|
+
SCHEMA_ARRAYS.each do |keyword|
|
|
693
|
+
Array(schema[keyword]).each_with_index do |member, index|
|
|
694
|
+
walk(member, ValidationSupport.index_path("#{path}.#{keyword}", index))
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def walk_values(schema, path)
|
|
700
|
+
values = @references_only ? REFERENCE_SCHEMA_VALUES : SCHEMA_VALUES
|
|
701
|
+
values.each do |keyword|
|
|
702
|
+
member = schema[keyword]
|
|
703
|
+
walk(member, "#{path}.#{keyword}") if member.is_a?(Hash)
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def walk_dependencies(schema, path)
|
|
708
|
+
schema.fetch("dependencies", {}).each do |key, member|
|
|
709
|
+
next if member.is_a?(Array)
|
|
710
|
+
|
|
711
|
+
walk(member, ValidationSupport.child_path("#{path}.dependencies", key))
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
private_constant :AssertionScanner
|
|
716
|
+
|
|
717
|
+
# Unsupported assertions are safe as MCP server guidance, but not as a
|
|
718
|
+
# claimed local validation boundary. External references are rejected in
|
|
719
|
+
# every mode so validation never implies hidden network or file resolution.
|
|
720
|
+
class AssertionContract
|
|
721
|
+
def initialize(complete:, context:, allow_guidance: false)
|
|
722
|
+
@complete = complete
|
|
723
|
+
@context = context
|
|
724
|
+
@allow_guidance = allow_guidance
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def call(schema)
|
|
728
|
+
findings = AssertionScanner.new.call(schema)
|
|
729
|
+
references = AssertionScanner.new.call(schema, references_only: true)
|
|
730
|
+
reject_external_references(references)
|
|
731
|
+
return if @complete || @allow_guidance || findings.empty?
|
|
732
|
+
|
|
733
|
+
paths = findings.first(3).map(&:first).join(", ")
|
|
734
|
+
suffix = findings.length > 3 ? ", and #{findings.length - 3} more" : ""
|
|
735
|
+
raise ConfigurationError,
|
|
736
|
+
"#{@context} uses assertions Mistri cannot validate at #{paths}#{suffix}"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
private
|
|
740
|
+
|
|
741
|
+
def reject_external_references(findings)
|
|
742
|
+
finding = findings.find do |(_, keyword, value)|
|
|
743
|
+
%w[$ref $dynamicRef].include?(keyword) && !value.empty? && !value.start_with?("#")
|
|
744
|
+
end
|
|
745
|
+
return unless finding
|
|
746
|
+
|
|
747
|
+
raise ConfigurationError,
|
|
748
|
+
"#{finding.first} must be empty or a same-document reference beginning with #"
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
private_constant :AssertionContract
|
|
752
|
+
|
|
753
|
+
# Core never approximates regex-key semantics. A full validator is explicit
|
|
754
|
+
# authority whenever patternProperties can match an argument key.
|
|
755
|
+
class ToolContract
|
|
756
|
+
def initialize(complete:)
|
|
757
|
+
@complete = complete
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def call(schema)
|
|
761
|
+
unless schema.is_a?(Hash) && schema["type"] == "object"
|
|
762
|
+
configuration!("$", "must declare type object for tool arguments")
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
walk(schema, "$")
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
private
|
|
769
|
+
|
|
770
|
+
def walk(schema, path)
|
|
771
|
+
return if schema.equal?(true) || schema.equal?(false)
|
|
772
|
+
|
|
773
|
+
require_complete_for_patterns(schema, path)
|
|
774
|
+
each_subschema(schema, path) { |member, member_path| walk(member, member_path) }
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
def require_complete_for_patterns(spec, path)
|
|
778
|
+
patterns = spec["patternProperties"]
|
|
779
|
+
return unless patterns.is_a?(Hash) && !patterns.empty?
|
|
780
|
+
return if @complete
|
|
781
|
+
|
|
782
|
+
configuration!("#{path}.patternProperties",
|
|
783
|
+
"non-empty pattern properties require a complete argument validator")
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def each_subschema(spec, path, &block)
|
|
787
|
+
%w[properties patternProperties $defs definitions dependentSchemas].each do |keyword|
|
|
788
|
+
spec.fetch(keyword, {}).each do |key, member|
|
|
789
|
+
block.call(member, ValidationSupport.child_path("#{path}.#{keyword}", key))
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
%w[allOf anyOf oneOf prefixItems].each do |keyword|
|
|
793
|
+
Array(spec[keyword]).each_with_index do |member, index|
|
|
794
|
+
block.call(member, ValidationSupport.index_path("#{path}.#{keyword}", index))
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
%w[
|
|
798
|
+
not if then else contains propertyNames unevaluatedProperties
|
|
799
|
+
unevaluatedItems additionalProperties
|
|
800
|
+
].each do |keyword|
|
|
801
|
+
member = spec[keyword]
|
|
802
|
+
block.call(member, "#{path}.#{keyword}") if member.is_a?(Hash)
|
|
803
|
+
end
|
|
804
|
+
items = spec["items"]
|
|
805
|
+
block.call(items, ValidationSupport.item_path(path)) if items.is_a?(Hash)
|
|
806
|
+
spec.fetch("dependencies", {}).each do |key, member|
|
|
807
|
+
next if member.is_a?(Array)
|
|
808
|
+
|
|
809
|
+
block.call(member, ValidationSupport.child_path("#{path}.dependencies", key))
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def configuration!(path, problem)
|
|
814
|
+
raise ConfigurationError, "#{path} #{problem}"
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
private_constant :ToolContract
|
|
818
|
+
|
|
819
|
+
# One immutable validation plan is reused for every call to a Tool.
|
|
820
|
+
class Compiled
|
|
821
|
+
attr_reader :schema
|
|
822
|
+
|
|
823
|
+
def initialize(schema, tool: false, complete: false)
|
|
824
|
+
@schema = DefinitionCompiler.new.call(schema)
|
|
825
|
+
ToolContract.new(complete: complete).call(@schema) if tool
|
|
826
|
+
freeze
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
def violations(value, path = "$", owned: false)
|
|
830
|
+
unless owned
|
|
831
|
+
value, error = ToolArguments.canonicalize(value)
|
|
832
|
+
if ToolArguments.resource_error?(error)
|
|
833
|
+
return ["#{ValidationSupport.root_path(path)} validation limit exceeded"]
|
|
834
|
+
end
|
|
835
|
+
return ["#{ValidationSupport.root_path(path)} must be valid JSON"] if error
|
|
836
|
+
end
|
|
837
|
+
PortableValidator.new.call(value, @schema, path)
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
private_constant :Compiled
|
|
841
|
+
|
|
842
|
+
# A task's prompt and local validator share one immutable schema. The marker
|
|
843
|
+
# lets providers omit native constraints they cannot represent faithfully.
|
|
844
|
+
class TaskPlan
|
|
845
|
+
attr_reader :schema
|
|
846
|
+
|
|
847
|
+
def initialize(validator)
|
|
848
|
+
@validator = validator
|
|
849
|
+
@schema = validator.schema
|
|
850
|
+
freeze
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def violations(value, path = "$")
|
|
854
|
+
@validator.violations(value, path)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def native_fallback? = true
|
|
858
|
+
end
|
|
859
|
+
private_constant :TaskPlan
|
|
860
|
+
|
|
861
|
+
# Runtime validation is deliberately smaller than JSON Schema. It never
|
|
862
|
+
# approximates pattern semantics; a Tool requiring them supplies a full validator.
|
|
863
|
+
class PortableValidator
|
|
107
864
|
TYPES = {
|
|
108
|
-
"object" => ->(
|
|
109
|
-
"
|
|
110
|
-
"
|
|
865
|
+
"object" => ->(value) { value.is_a?(Hash) },
|
|
866
|
+
"array" => ->(value) { value.is_a?(Array) },
|
|
867
|
+
"string" => ->(value) { value.is_a?(String) },
|
|
868
|
+
"integer" => ->(value) { ValidationSupport.json_integer?(value) },
|
|
869
|
+
"number" => ->(value) { ValidationSupport.json_number?(value) },
|
|
870
|
+
"boolean" => ->(value) { value.equal?(true) || value.equal?(false) },
|
|
111
871
|
"null" => lambda(&:nil?)
|
|
112
872
|
}.freeze
|
|
113
873
|
|
|
874
|
+
def initialize
|
|
875
|
+
@errors = []
|
|
876
|
+
@root_path = "$"
|
|
877
|
+
@truncated = false
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def call(value, schema, path)
|
|
881
|
+
@root_path = ValidationSupport.root_path(path)
|
|
882
|
+
walk(value, schema, @root_path)
|
|
883
|
+
@errors
|
|
884
|
+
end
|
|
885
|
+
|
|
114
886
|
private
|
|
115
887
|
|
|
888
|
+
def walk(value, schema, path)
|
|
889
|
+
return if full? || schema.equal?(true)
|
|
890
|
+
return add("#{path} is not allowed") if schema.equal?(false)
|
|
891
|
+
|
|
892
|
+
mismatch = type_violation(value, schema, path)
|
|
893
|
+
return add(mismatch) if mismatch
|
|
894
|
+
|
|
895
|
+
enum = schema["enum"]
|
|
896
|
+
add("#{path} must match enum") if enum && !enum.include?(value)
|
|
897
|
+
return if full?
|
|
898
|
+
|
|
899
|
+
object_violations(value, schema, path) if value.is_a?(Hash)
|
|
900
|
+
array_violations(value, schema, path) if value.is_a?(Array)
|
|
901
|
+
end
|
|
902
|
+
|
|
116
903
|
def type_violation(value, spec, path)
|
|
117
|
-
|
|
118
|
-
return nil if names.empty?
|
|
119
|
-
return nil if names.any? { |name| TYPES.fetch(name, ->(_) { true }).call(value) }
|
|
904
|
+
return unless spec.key?("type")
|
|
120
905
|
|
|
121
|
-
|
|
906
|
+
names = spec["type"].is_a?(Array) ? spec["type"] : [spec["type"]]
|
|
907
|
+
return if names.any? { |name| TYPES.fetch(name).call(value) }
|
|
908
|
+
|
|
909
|
+
"#{path} must be #{names.join(" or ")}, got #{ValidationSupport.json_type(value)}"
|
|
122
910
|
end
|
|
123
911
|
|
|
124
912
|
def object_violations(value, spec, path)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"#{path
|
|
913
|
+
properties = spec.fetch("properties", {})
|
|
914
|
+
spec.fetch("required", []).each do |key|
|
|
915
|
+
add("#{ValidationSupport.child_path(path, key)} is required") unless value.key?(key)
|
|
916
|
+
break if full?
|
|
128
917
|
end
|
|
918
|
+
return if full?
|
|
919
|
+
|
|
129
920
|
value.each do |key, member|
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
921
|
+
if properties.key?(key)
|
|
922
|
+
walk(member, properties[key], ValidationSupport.child_path(path, key))
|
|
923
|
+
else
|
|
924
|
+
validate_additional(member, key, spec, path)
|
|
134
925
|
end
|
|
926
|
+
break if full?
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def validate_additional(member, key, spec, path)
|
|
931
|
+
additional = spec.fetch("additionalProperties", true)
|
|
932
|
+
patterns = spec["patternProperties"]
|
|
933
|
+
return if patterns.is_a?(Hash) && !patterns.empty? && additional != true
|
|
934
|
+
return if additional.equal?(true)
|
|
935
|
+
|
|
936
|
+
member_path = ValidationSupport.child_path(path, key)
|
|
937
|
+
if additional.equal?(false)
|
|
938
|
+
add("#{member_path} is not allowed")
|
|
939
|
+
else
|
|
940
|
+
walk(member, additional, member_path)
|
|
135
941
|
end
|
|
136
|
-
errors
|
|
137
942
|
end
|
|
138
943
|
|
|
139
944
|
def array_violations(value, spec, path)
|
|
945
|
+
prefix = spec.fetch("prefixItems", [])
|
|
946
|
+
prefix.each_with_index do |member_schema, index|
|
|
947
|
+
break if index >= value.length
|
|
948
|
+
|
|
949
|
+
walk(value[index], member_schema, ValidationSupport.index_path(path, index))
|
|
950
|
+
break if full?
|
|
951
|
+
end
|
|
952
|
+
return if full?
|
|
953
|
+
|
|
140
954
|
items = spec["items"]
|
|
141
|
-
return
|
|
955
|
+
return unless items.is_a?(Hash) || items.equal?(true) || items.equal?(false)
|
|
956
|
+
|
|
957
|
+
index = prefix.length
|
|
958
|
+
while index < value.length
|
|
959
|
+
walk(value[index], items, ValidationSupport.index_path(path, index))
|
|
960
|
+
return if full?
|
|
142
961
|
|
|
143
|
-
|
|
144
|
-
violations(member, items, "#{path}[#{index}]")
|
|
962
|
+
index += 1
|
|
145
963
|
end
|
|
146
964
|
end
|
|
147
965
|
|
|
148
|
-
def
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
when Numeric then "number"
|
|
155
|
-
when true, false then "boolean"
|
|
156
|
-
when nil then "null"
|
|
157
|
-
else value.class.name
|
|
966
|
+
def add(message)
|
|
967
|
+
if @errors.length < MAX_VIOLATIONS - 1
|
|
968
|
+
@errors << message
|
|
969
|
+
else
|
|
970
|
+
@errors << "#{@root_path} additional violations omitted"
|
|
971
|
+
@truncated = true
|
|
158
972
|
end
|
|
159
973
|
end
|
|
974
|
+
|
|
975
|
+
def full? = @truncated
|
|
160
976
|
end
|
|
977
|
+
private_constant :PortableValidator, :MAX_VIOLATIONS, :MAX_DEPTH, :MAX_NODES,
|
|
978
|
+
:MAX_BYTES, :JSON_TYPES
|
|
161
979
|
end
|
|
162
980
|
end
|