k9o 1.4.0 → 1.5.2

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.
data/lib/k9o/moat.rb ADDED
@@ -0,0 +1,443 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+
6
+ module K9o
7
+ # Moat: a local guard around one model call. It runs in your process; it makes no network calls, needs no account
8
+ # and uses no other part of this SDK.
9
+ #
10
+ # moat = K9o::Moat.new(
11
+ # prompt: "Summarise the ticket in one sentence.",
12
+ # input_schema: { "type" => "string", "maxLength" => 4000 },
13
+ # output_schema: { "type" => "object", "required" => ["summary"], "properties" => { "summary" => { "type" => "string" } } },
14
+ # filters: { pii: ->(text) { barbican.inspect(text).decision == "deny" } }
15
+ # )
16
+ # messages = moat.messages(ticket_text) # [{ role: "system", ... }, { role: "user", ... }] or raises Refusal
17
+ # value = moat.check_output(model_text) # the parsed, validated value or raises Refusal
18
+ #
19
+ # It does four things:
20
+ #
21
+ # 1. Pins the system prompt. Moat builds the message list itself: the prompt is always the +system+ message and the
22
+ # untrusted input is always a +user+ message. Input that carries a role or system marker is refused. The pin is
23
+ # structural: it stops injected text from becoming the system prompt. It does not stop that text reaching the
24
+ # model, so keep +output_schema+ and the checks on what your code does with the answer.
25
+ # 2. Checks the input against +input_schema+ before it goes into a message.
26
+ # 3. Checks the model output against +output_schema+.
27
+ # 4. Runs the optional +pii+ filter over the output.
28
+ #
29
+ # A refusal is an exception: Moat::Refusal, with a +reason+ from the closed set REASONS and a short +detail+ that
30
+ # never repeats the input or the output.
31
+ module Moat
32
+ REASONS = %i[role_marker input_schema output_schema pii].freeze
33
+
34
+ # Raised when Moat refuses input or output. +reason+ is one of REASONS.
35
+ class Refusal < StandardError
36
+ attr_reader :reason, :detail
37
+
38
+ def initialize(reason, detail)
39
+ @reason = reason
40
+ @detail = detail
41
+ super("#{reason}: #{detail}")
42
+ end
43
+ end
44
+
45
+ # Raised at construction for a bad prompt, filter or schema.
46
+ class ConfigError < ArgumentError; end
47
+
48
+ # Raised at construction when a schema uses a keyword outside the supported subset.
49
+ class SchemaError < ConfigError; end
50
+
51
+ # Build a Guard. See Guard#initialize.
52
+ def self.new(**options)
53
+ Guard.new(**options)
54
+ end
55
+
56
+ # The JSON Schema subset Moat understands, and nothing else.
57
+ module Schema
58
+ TYPES = %w[object array string number integer boolean null].freeze
59
+ KEYWORDS = %w[type properties required additionalProperties items enum const minLength maxLength pattern
60
+ minimum maximum minItems maxItems].freeze
61
+
62
+ module_function
63
+
64
+ # Raise SchemaError unless +schema+ uses only the supported keywords with well-formed values.
65
+ def check_schema!(schema, path = '$')
66
+ raise SchemaError, "#{path}: a schema must be a Hash" unless schema.is_a?(Hash)
67
+
68
+ schema.each_key do |key|
69
+ unless KEYWORDS.include?(key.to_s)
70
+ raise SchemaError, "#{path}: unsupported keyword #{key.to_s.inspect}; supported: #{KEYWORDS.join(', ')}"
71
+ end
72
+ end
73
+ s = stringify(schema)
74
+ check_type!(s['type'], path) if s.key?('type')
75
+ check_properties!(s['properties'], path) if s.key?('properties')
76
+ check_required!(s['required'], path) if s.key?('required')
77
+ if s.key?('additionalProperties') && ![true, false].include?(s['additionalProperties'])
78
+ raise SchemaError, "#{path}.additionalProperties must be true or false"
79
+ end
80
+ check_schema!(s['items'], "#{path}.items") if s.key?('items')
81
+ raise SchemaError, "#{path}.enum must be a non-empty Array" if s.key?('enum') && !(s['enum'].is_a?(Array) && !s['enum'].empty?)
82
+
83
+ %w[minLength maxLength minItems maxItems].each do |kw|
84
+ next unless s.key?(kw)
85
+
86
+ raise SchemaError, "#{path}.#{kw} must be a whole number >= 0" unless s[kw].is_a?(Integer) && s[kw] >= 0
87
+ end
88
+ %w[minimum maximum].each do |kw|
89
+ next unless s.key?(kw)
90
+
91
+ raise SchemaError, "#{path}.#{kw} must be a number" unless s[kw].is_a?(Numeric) && s[kw].to_f.finite?
92
+ end
93
+ check_pattern!(s['pattern'], path) if s.key?('pattern')
94
+ true
95
+ end
96
+
97
+ def stringify(schema)
98
+ schema.transform_keys(&:to_s)
99
+ end
100
+
101
+ def check_type!(type, path)
102
+ list = type.is_a?(Array) ? type : [type]
103
+ ok = !list.empty? && list.all? { |t| t.is_a?(String) && TYPES.include?(t) }
104
+ raise SchemaError, "#{path}.type must be one of #{TYPES.join(', ')} (or a list of them)" unless ok
105
+ end
106
+
107
+ def check_properties!(props, path)
108
+ raise SchemaError, "#{path}.properties must be a Hash of schemas" unless props.is_a?(Hash)
109
+
110
+ props.each { |name, sub| check_schema!(sub, "#{path}.properties.#{name}") }
111
+ end
112
+
113
+ def check_required!(required, path)
114
+ ok = required.is_a?(Array) && required.all? { |r| r.is_a?(String) || r.is_a?(Symbol) }
115
+ raise SchemaError, "#{path}.required must be an Array of names" unless ok
116
+ end
117
+
118
+ def check_pattern!(pattern, path)
119
+ raise SchemaError, "#{path}.pattern must be a String" unless pattern.is_a?(String)
120
+
121
+ Regexp.new(pattern)
122
+ rescue RegexpError => e
123
+ raise SchemaError, "#{path}.pattern is not a valid regular expression: #{e.message}"
124
+ end
125
+
126
+ # nil when +value+ (JSON data) satisfies +schema+, else a short description of the first failure.
127
+ def violation(schema, value, path = '$')
128
+ s = stringify(schema)
129
+ if s.key?('type') && !type_ok?(s['type'], value)
130
+ return "#{path}: expected #{Array(s['type']).join(' or ')}"
131
+ end
132
+ return "#{path}: not one of the allowed values" if s.key?('enum') && s['enum'].none? { |e| same?(e, value) }
133
+ return "#{path}: not the required constant" if s.key?('const') && !same?(s['const'], value)
134
+
135
+ case value
136
+ when String then string_violation(s, value, path)
137
+ when Numeric then number_violation(s, value, path)
138
+ when Array then array_violation(s, value, path)
139
+ when Hash then object_violation(s, value, path)
140
+ end
141
+ end
142
+
143
+ def type_ok?(type, value)
144
+ Array(type).any? do |t|
145
+ case t
146
+ when 'object' then value.is_a?(Hash)
147
+ when 'array' then value.is_a?(Array)
148
+ when 'string' then value.is_a?(String)
149
+ when 'number' then value.is_a?(Numeric)
150
+ when 'integer' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.floor)
151
+ when 'boolean' then value == true || value == false
152
+ when 'null' then value.nil?
153
+ end
154
+ end
155
+ end
156
+
157
+ def same?(left, right)
158
+ return false unless left.class == right.class || (left.is_a?(Numeric) && right.is_a?(Numeric))
159
+
160
+ case left
161
+ when Hash
162
+ left = left.transform_keys(&:to_s)
163
+ left.size == right.size && left.all? { |k, v| right.key?(k) && same?(v, right[k]) }
164
+ when Array
165
+ left.size == right.size && left.each_with_index.all? { |v, i| same?(v, right[i]) }
166
+ else
167
+ left == right
168
+ end
169
+ end
170
+
171
+ def string_violation(s, value, path)
172
+ return "#{path}: shorter than minLength" if s['minLength'] && value.length < s['minLength']
173
+ return "#{path}: longer than maxLength" if s['maxLength'] && value.length > s['maxLength']
174
+ return "#{path}: does not match pattern" if s['pattern'] && !Regexp.new(s['pattern']).match?(value)
175
+
176
+ nil
177
+ end
178
+
179
+ def number_violation(s, value, path)
180
+ return "#{path}: below minimum" if s['minimum'] && value < s['minimum']
181
+ return "#{path}: above maximum" if s['maximum'] && value > s['maximum']
182
+
183
+ nil
184
+ end
185
+
186
+ def array_violation(s, value, path)
187
+ return "#{path}: fewer than minItems" if s['minItems'] && value.size < s['minItems']
188
+ return "#{path}: more than maxItems" if s['maxItems'] && value.size > s['maxItems']
189
+ return nil unless s['items']
190
+
191
+ value.each_with_index do |item, index|
192
+ found = violation(s['items'], item, "#{path}[#{index}]")
193
+ return found if found
194
+ end
195
+ nil
196
+ end
197
+
198
+ def object_violation(s, value, path)
199
+ props = (s['properties'] || {}).transform_keys(&:to_s)
200
+ Array(s['required']).map(&:to_s).each do |name|
201
+ return "#{path}.#{name}: required" unless value.key?(name)
202
+ end
203
+ value.each do |name, item|
204
+ if props.key?(name)
205
+ found = violation(props[name], item, "#{path}.#{name}")
206
+ return found if found
207
+ elsif s['additionalProperties'] == false
208
+ return "#{path}.#{name}: not allowed"
209
+ end
210
+ end
211
+ nil
212
+ end
213
+ end
214
+
215
+ # Detects role and system markers in untrusted input.
216
+ module Markers
217
+ # Default-ignorable and format code points, removed before matching (one shared table, code point ranges).
218
+ IGNORABLE_RANGES = [
219
+ 0x00AD..0x00AD, 0x034F..0x034F, 0x061C..0x061C, 0x115F..0x1160, 0x17B4..0x17B5, 0x180B..0x180F,
220
+ 0x200B..0x200F, 0x202A..0x202E, 0x2060..0x206F, 0x3164..0x3164, 0xFE00..0xFE0F, 0xFEFF..0xFEFF,
221
+ 0xFFA0..0xFFA0, 0xFFF0..0xFFF8, 0x1BCA0..0x1BCA3, 0x1D173..0x1D17A, 0xE0000..0xE0FFF
222
+ ].freeze
223
+ IGNORABLE = Regexp.new(
224
+ '[' + IGNORABLE_RANGES.map { |r| format('\\u{%X}-\\u{%X}', r.first, r.last) }.join + ']'
225
+ ).freeze
226
+ # Cyrillic and Greek look-alikes of the marker letters -> Latin letter (code point escapes; mirrors
227
+ # test/fixtures/moat-confusables.json, which a test compares against). Not general homoglyph detection.
228
+ CONFUSABLES = {
229
+ "\u{0430}" => 'a',
230
+ "\u{0410}" => 'a',
231
+ "\u{0441}" => 'c',
232
+ "\u{0421}" => 'c',
233
+ "\u{0435}" => 'e',
234
+ "\u{0415}" => 'e',
235
+ "\u{0456}" => 'i',
236
+ "\u{0406}" => 'i',
237
+ "\u{043E}" => 'o',
238
+ "\u{041E}" => 'o',
239
+ "\u{0440}" => 'p',
240
+ "\u{0420}" => 'p',
241
+ "\u{0455}" => 's',
242
+ "\u{0405}" => 's',
243
+ "\u{0443}" => 'y',
244
+ "\u{0423}" => 'y',
245
+ "\u{0501}" => 'd',
246
+ "\u{04CF}" => 'l',
247
+ "\u{04C0}" => 'l',
248
+ "\u{0422}" => 't',
249
+ "\u{041C}" => 'm',
250
+ "\u{041D}" => 'h',
251
+ "\u{0445}" => 'x',
252
+ "\u{0425}" => 'x',
253
+ "\u{0391}" => 'a',
254
+ "\u{03B1}" => 'a',
255
+ "\u{0395}" => 'e',
256
+ "\u{03B5}" => 'e',
257
+ "\u{0399}" => 'i',
258
+ "\u{03B9}" => 'i',
259
+ "\u{039F}" => 'o',
260
+ "\u{03BF}" => 'o',
261
+ "\u{03A1}" => 'p',
262
+ "\u{03C1}" => 'p',
263
+ "\u{03A4}" => 't',
264
+ "\u{03C4}" => 't',
265
+ "\u{03A5}" => 'y',
266
+ "\u{03C5}" => 'u',
267
+ "\u{03BD}" => 'v',
268
+ "\u{039C}" => 'm',
269
+ "\u{039D}" => 'n',
270
+ "\u{03A7}" => 'x',
271
+ "\u{03C7}" => 'x',
272
+ "\u{03F2}" => 'c',
273
+ "\u{03F9}" => 'c',
274
+ "\u{0397}" => 'h',
275
+ "\u{0442}" => 't'
276
+ }.freeze
277
+ CONFUSABLES_RE = Regexp.new("[" + CONFUSABLES.keys.join + "]").freeze
278
+ MARKS = /[\p{Mn}\p{Me}]/.freeze
279
+ HORIZONTAL_SPACE = /[\t\p{Zs}]+/.freeze
280
+ LINE_ROLE = /^[ \t]*(system|assistant|developer|tool|user)[ \t]*:/.freeze
281
+ STRINGS = ['<|system|>', '<|im_start|>', '<|im_end|>', '[inst]', '<<sys>>'].freeze
282
+ HEADINGS = /###[ \t]*(system|instruction)/.freeze
283
+ JSON_ROLE = /"role"\s*:\s*"\s*(system|developer|assistant)\s*"/.freeze
284
+ ROLE_VALUES = %w[system developer assistant].freeze
285
+
286
+ module_function
287
+
288
+ # The name of the first marker found anywhere in +value+ (JSON data), or nil.
289
+ def find(value)
290
+ case value
291
+ when String then in_text(value)
292
+ when Array then value.lazy.map { |item| find(item) }.find { |found| found }
293
+ when Hash then in_hash(value)
294
+ end
295
+ end
296
+
297
+ def in_hash(hash)
298
+ hash.each do |key, item|
299
+ if normalise(key.to_s) == 'role' && item.is_a?(String) && ROLE_VALUES.include?(normalise(item).strip)
300
+ return 'a role object'
301
+ end
302
+ found = find(key.to_s) || find(item)
303
+ return found if found
304
+ end
305
+ nil
306
+ end
307
+
308
+ def in_text(text)
309
+ t = normalise(text)
310
+ return 'a role line' if t.match?(LINE_ROLE)
311
+ return 'a chat-template marker' if STRINGS.any? { |m| t.include?(m) }
312
+ return 'a heading marker' if t.match?(HEADINGS)
313
+ return 'a role object' if t.match?(JSON_ROLE)
314
+
315
+ nil
316
+ end
317
+
318
+ def normalise(text)
319
+ nfkc = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub('').unicode_normalize(:nfkc)
320
+ nfkc.unicode_normalize(:nfkd).gsub(MARKS, '').gsub(IGNORABLE, '')
321
+ .gsub(CONFUSABLES_RE, CONFUSABLES).downcase(:fold).gsub(HORIZONTAL_SPACE, ' ')
322
+ end
323
+ end
324
+
325
+ class Guard
326
+ # +prompt+: the pinned system prompt (a non-empty String).
327
+ # +input_schema+ / +output_schema+: optional schemas in the subset (see Schema); anything else raises SchemaError.
328
+ # +filters+: optional { pii: callable }. The callable gets the output as text and returns a truthy value when it
329
+ # finds personal data; its return value is not repeated in the refusal. An error inside it also refuses.
330
+ def initialize(prompt:, input_schema: nil, output_schema: nil, filters: nil)
331
+ raise ConfigError, 'prompt must be a non-empty String' unless prompt.is_a?(String) && !prompt.strip.empty?
332
+
333
+ Schema.check_schema!(input_schema, 'input_schema') unless input_schema.nil?
334
+ Schema.check_schema!(output_schema, 'output_schema') unless output_schema.nil?
335
+ @prompt = prompt.dup.freeze
336
+ @input_schema = input_schema
337
+ @output_schema = output_schema
338
+ @pii = pii_filter(filters)
339
+ end
340
+
341
+ # The message list for one model call: the pinned prompt as +system+, the untrusted input as +user+ text.
342
+ # Raises Refusal (:role_marker or :input_schema).
343
+ def messages(untrusted_input)
344
+ data = json_data(untrusted_input, :input_schema, 'input is not JSON data')
345
+ marker = Markers.find(data)
346
+ raise Refusal.new(:role_marker, "the input contains #{marker}") if marker
347
+
348
+ check_schema(@input_schema, data, :input_schema)
349
+ text = data.is_a?(String) ? data : JSON.generate(data)
350
+ [{ role: 'system', content: @prompt }, { role: 'user', content: text }].freeze
351
+ end
352
+
353
+ # The validated, filtered model output. A String is parsed as JSON unless the output schema's type is "string".
354
+ # Raises Refusal (:output_schema or :pii).
355
+ def check_output(model_output)
356
+ value = parse_output(model_output)
357
+ check_schema(@output_schema, value, :output_schema)
358
+ run_pii(value)
359
+ value
360
+ end
361
+
362
+ # Lowercase hex SHA-256 of the pinned prompt, so you can pass it to Rampart or record it.
363
+ def prompt_sha256
364
+ Digest::SHA256.hexdigest(@prompt.b)
365
+ end
366
+
367
+ private
368
+
369
+ def pii_filter(filters)
370
+ return nil if filters.nil?
371
+ raise ConfigError, 'filters must be a Hash' unless filters.is_a?(Hash)
372
+
373
+ named = filters.transform_keys(&:to_s)
374
+ unknown = named.keys - ['pii']
375
+ raise ConfigError, "unknown filter #{unknown.first.inspect}; supported: pii" unless unknown.empty?
376
+
377
+ callable = named['pii']
378
+ return nil if callable.nil?
379
+ raise ConfigError, 'filters[:pii] must respond to call' unless callable.respond_to?(:call)
380
+
381
+ callable
382
+ end
383
+
384
+ # +value+ as plain JSON data (string keys), or a Refusal when it holds anything JSON cannot.
385
+ def json_data(value, reason, detail)
386
+ plain(value)
387
+ rescue TypeError, EncodingError
388
+ raise Refusal.new(reason, detail)
389
+ end
390
+
391
+ def plain(value)
392
+ case value
393
+ when nil, true, false, String then value
394
+ when Symbol then value.to_s
395
+ when Integer then value
396
+ when Float
397
+ raise TypeError, 'not finite' unless value.finite?
398
+
399
+ value
400
+ when Array then value.map { |item| plain(item) }
401
+ when Hash
402
+ value.each_with_object({}) do |(key, item), out|
403
+ raise TypeError, 'keys must be strings' unless key.is_a?(String) || key.is_a?(Symbol)
404
+
405
+ out[key.to_s] = plain(item)
406
+ end
407
+ else
408
+ raise TypeError, 'not JSON data'
409
+ end
410
+ end
411
+
412
+ def parse_output(model_output)
413
+ string_schema = @output_schema && Schema.stringify(@output_schema)['type'] == 'string'
414
+ return model_output unless model_output.is_a?(String) && @output_schema && !string_schema
415
+
416
+ JSON.parse(model_output)
417
+ rescue JSON::ParserError
418
+ raise Refusal.new(:output_schema, 'the output is not valid JSON')
419
+ end
420
+
421
+ def check_schema(schema, value, reason)
422
+ return if schema.nil?
423
+
424
+ found = Schema.violation(schema, value)
425
+ raise Refusal.new(reason, found) if found
426
+ end
427
+
428
+ def run_pii(value)
429
+ return if @pii.nil?
430
+
431
+ text = value.is_a?(String) ? value : JSON.generate(value)
432
+ flagged = begin
433
+ @pii.call(text)
434
+ rescue StandardError => e
435
+ raise Refusal.new(:pii, "the PII filter failed (#{e.class}), so the output is withheld")
436
+ end
437
+ return if flagged.nil? || flagged == false || (flagged.respond_to?(:empty?) && flagged.empty?)
438
+
439
+ raise Refusal.new(:pii, 'the PII filter flagged the output')
440
+ end
441
+ end
442
+ end
443
+ end
data/lib/k9o/org.rb CHANGED
@@ -140,13 +140,12 @@ module K9o
140
140
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('identity-providers'))
141
141
  end
142
142
 
143
- # POST /identity-providers { idpProvider, displayName, clientId, clientSecretRef, tierGating, ... }
143
+ # POST /identity-providers { idpProvider, displayName, clientId, clientSecretRef, ... }
144
144
  # idp_provider: google github microsoft oidc saml scim ldap active_directory apple facebook twitter
145
- # tier_gating: 'free_tier_ok' or 'ridge_plus'
146
- # client_secret_ref: a reference to the stored client secret, never the secret itself
147
- def create(idp_provider:, display_name:, client_id:, client_secret_ref:, tier_gating:, **opts)
145
+ # client_secret_ref: the OAuth client secret itself (the name is kept for compatibility); the gateway encrypts it and never returns it
146
+ def create(idp_provider:, display_name:, client_id:, client_secret_ref:, **opts)
148
147
  body = { 'idpProvider' => idp_provider, 'displayName' => display_name, 'clientId' => client_id,
149
- 'clientSecretRef' => client_secret_ref, 'tierGating' => tier_gating }
148
+ 'clientSecretRef' => client_secret_ref }
150
149
  opts.each do |key, value|
151
150
  wire = CREATE_OPTIONAL[key]
152
151
  raise ArgumentError, "unknown idp_providers.create option: #{key}" if wire.nil?