vangrail 0.1.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +473 -0
  4. data/lib/vangrail/actions.rb +61 -0
  5. data/lib/vangrail/chat.rb +88 -0
  6. data/lib/vangrail/client/completion.rb +122 -0
  7. data/lib/vangrail/client.rb +219 -0
  8. data/lib/vangrail/colang/ast.rb +53 -0
  9. data/lib/vangrail/colang/interpreter.rb +131 -0
  10. data/lib/vangrail/colang/library.rb +53 -0
  11. data/lib/vangrail/colang/parser.rb +222 -0
  12. data/lib/vangrail/config.rb +270 -0
  13. data/lib/vangrail/confusables.rb +67 -0
  14. data/lib/vangrail/confusables_data.rb +1673 -0
  15. data/lib/vangrail/conversation.rb +105 -0
  16. data/lib/vangrail/engine.rb +240 -0
  17. data/lib/vangrail/errors.rb +48 -0
  18. data/lib/vangrail/http.rb +109 -0
  19. data/lib/vangrail/parsers.rb +181 -0
  20. data/lib/vangrail/policies.rb +202 -0
  21. data/lib/vangrail/prompt.rb +88 -0
  22. data/lib/vangrail/provider.rb +191 -0
  23. data/lib/vangrail/providers/gateway.rb +131 -0
  24. data/lib/vangrail/providers/llmlite.rb +71 -0
  25. data/lib/vangrail/providers.rb +72 -0
  26. data/lib/vangrail/rail.rb +93 -0
  27. data/lib/vangrail/rails/budget.rb +63 -0
  28. data/lib/vangrail/rails/canary.rb +76 -0
  29. data/lib/vangrail/rails/colang_flow.rb +40 -0
  30. data/lib/vangrail/rails/escalation.rb +178 -0
  31. data/lib/vangrail/rails/exfiltration.rb +167 -0
  32. data/lib/vangrail/rails/grounding.rb +64 -0
  33. data/lib/vangrail/rails/guard_model.rb +96 -0
  34. data/lib/vangrail/rails/hidden.rb +105 -0
  35. data/lib/vangrail/rails/injected_instructions.rb +86 -0
  36. data/lib/vangrail/rails/jailbreak.rb +114 -0
  37. data/lib/vangrail/rails/known_answer.rb +118 -0
  38. data/lib/vangrail/rails/many_shot.rb +80 -0
  39. data/lib/vangrail/rails/markup.rb +77 -0
  40. data/lib/vangrail/rails/missing.rb +38 -0
  41. data/lib/vangrail/rails/obfuscation.rb +186 -0
  42. data/lib/vangrail/rails/pattern.rb +57 -0
  43. data/lib/vangrail/rails/personal_data.rb +152 -0
  44. data/lib/vangrail/rails/remote.rb +40 -0
  45. data/lib/vangrail/rails/secrets.rb +77 -0
  46. data/lib/vangrail/rails/self_check.rb +81 -0
  47. data/lib/vangrail/rails/trajectory.rb +101 -0
  48. data/lib/vangrail/result.rb +114 -0
  49. data/lib/vangrail/result_cache.rb +0 -0
  50. data/lib/vangrail/spotlight.rb +157 -0
  51. data/lib/vangrail/stream_guard.rb +163 -0
  52. data/lib/vangrail/version.rb +5 -0
  53. data/lib/vangrail.rb +354 -0
  54. metadata +120 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'engine'
4
+ require_relative 'result'
5
+
6
+ module Vangrail
7
+ # A dialogue, so rails can see more than the turn in front of them.
8
+ #
9
+ # Every rail so far reads one string. That is enough for the attacks that fit
10
+ # in one string, and it is exactly wrong for the ones built out of turns that
11
+ # are individually unremarkable: ask something harmless, ask for more detail
12
+ # about the part of the answer that helps, keep going until the thing you
13
+ # wanted is on screen. No single message in that sequence looks like an
14
+ # attack, because none of them is one.
15
+ #
16
+ # So this holds the turns and threads them into the rail context as
17
+ # `:history`, which the rail protocol has always carried and nothing has ever
18
+ # filled in. A rail that ignores history behaves exactly as before.
19
+ #
20
+ # convo = Vangrail::Conversation.new(engine)
21
+ # verdict = convo.ask(question)
22
+ # convo.answer(text) if verdict.allowed?
23
+ #
24
+ # What it also does is remember the verdicts. A refusal is the most
25
+ # informative event in a dialogue: the next message is either an ordinary
26
+ # follow-up or the same request rewritten, and telling those apart is
27
+ # impossible without knowing a refusal happened.
28
+ class Conversation
29
+ Turn = Struct.new(:role, :text, :result, keyword_init: true) do
30
+ def blocked?
31
+ result&.blocked? || false
32
+ end
33
+
34
+ def user?
35
+ role == :user
36
+ end
37
+
38
+ def to_h
39
+ { 'role' => role.to_s, 'text' => text, 'result' => result&.to_h }.compact
40
+ end
41
+ end
42
+
43
+ # How many turns of history the rails see. A dialogue that has been running
44
+ # for an hour is mostly irrelevant to whether this message is a retry, and
45
+ # an unbounded window makes the cost of a check grow with the session.
46
+ DEFAULT_WINDOW = 12
47
+
48
+ attr_reader :engine, :turns, :window
49
+
50
+ def initialize(engine, window: DEFAULT_WINDOW, **context)
51
+ @engine = engine
52
+ @window = window
53
+ @base_context = context
54
+ @turns = []
55
+ end
56
+
57
+ # Checks a question and records it, whatever the verdict. A blocked turn
58
+ # stays in the history: it is the part the next check needs most.
59
+ def ask(text, **context)
60
+ result = engine.check_input(text, history: history, **@base_context, **context)
61
+ @turns << Turn.new(role: :user, text: text.to_s, result: result)
62
+ result
63
+ end
64
+
65
+ def answer(text, **context)
66
+ result = engine.check_output(text, history: history, **@base_context, **context)
67
+ @turns << Turn.new(role: :assistant, text: content_of(result, text), result: result)
68
+ result
69
+ end
70
+
71
+ # Screens retrieved documents with the dialogue in view, so a context rail
72
+ # can see which question they were fetched for.
73
+ def screen(documents, **context)
74
+ engine.screen(documents, history: history, **@base_context, **context)
75
+ end
76
+
77
+ # The window the rails read: role and text, no Result objects, because a
78
+ # rail should not be reasoning about another rail's verdict text.
79
+ def history
80
+ turns.last(window).map { |t| { role: t.role, text: t.text, blocked: t.blocked? } }
81
+ end
82
+
83
+ def blocked_turns
84
+ turns.select { |t| t.user? && t.blocked? }
85
+ end
86
+
87
+ def blocked?
88
+ !blocked_turns.empty?
89
+ end
90
+
91
+ def last_user_turn
92
+ turns.reverse.find(&:user?)
93
+ end
94
+
95
+ def to_h
96
+ { 'turns' => turns.map(&:to_h), 'blocked' => blocked_turns.size }
97
+ end
98
+
99
+ private
100
+
101
+ def content_of(result, fallback)
102
+ result.respond_to?(:content_or) ? result.content_or(fallback.to_s) : fallback.to_s
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'rail'
5
+ require_relative 'result'
6
+ require_relative 'result_cache'
7
+
8
+ module Vangrail
9
+ # Runs ordered rails over text and reports one Result.
10
+ #
11
+ # The rules are short enough to state in full:
12
+ #
13
+ # - Rails run in the order given. The first :blocked ends the pass.
14
+ # - A :modified result replaces the text for every rail after it, and the
15
+ # engine reports :modified unless something later blocks.
16
+ # - A rail that raises is not a rail that passed. `on_error: :allow` (the
17
+ # default) keeps going and marks the pass uncertain; `:block` stops.
18
+ # - An empty rail list returns :passed with certain false. Nothing ran.
19
+ #
20
+ # Threading rewrites through later rails is the part worth being explicit
21
+ # about: a redaction rail that runs before a policy rail should have the
22
+ # policy rail judge the redacted text, not the original.
23
+ class Engine
24
+ attr_reader :input_rails, :context_rails, :output_rails, :on_error, :cache
25
+
26
+ def initialize(input: [], context: [], output: [], on_error: :allow, cache: true)
27
+ @input_rails = Array(input)
28
+ @context_rails = Array(context)
29
+ @output_rails = Array(output)
30
+ @on_error = on_error.to_sym
31
+ raise ArgumentError, 'on_error must be :allow or :block' unless %i[allow block].include?(@on_error)
32
+
33
+ @cache = cache.is_a?(ResultCache) ? cache : (ResultCache.new if cache)
34
+ end
35
+
36
+ def check_input(text, context = {})
37
+ run(:input, input_rails, text, context)
38
+ end
39
+
40
+ def check_output(text, user_input: nil, passages: nil, **context)
41
+ run(:output, output_rails, text, context.merge(user_input: user_input, passages: passages))
42
+ end
43
+
44
+ # One retrieved document, before it goes anywhere near a prompt.
45
+ def check_context(text, **context)
46
+ run(:context, context_rails, text, context)
47
+ end
48
+
49
+ # Screens a set of retrieved documents and reports what survived.
50
+ #
51
+ # A document that fails is dropped rather than failing the whole turn. One
52
+ # poisoned wiki page should cost a reader that page, not their answer, and
53
+ # an application that refuses outright teaches its readers that the
54
+ # guardrail is the problem.
55
+ def screen(documents, **context)
56
+ kept = []
57
+ rejected = []
58
+ uncertain = nil
59
+
60
+ Array(documents).each_with_index do |document, index|
61
+ result = check_context(text_of(document), **context, document: document, index: index)
62
+ uncertain ||= result unless result.certain?
63
+ if result.blocked?
64
+ rejected << { document: document, result: result }
65
+ else
66
+ kept << (result.modified? ? replace_text(document, result.content) : document)
67
+ end
68
+ end
69
+
70
+ Screening.new(kept: kept, rejected: rejected, certain: uncertain.nil?, reason: uncertain&.reason)
71
+ end
72
+
73
+ # What screen returns. `certain` means what it means on a Result: false says
74
+ # a rail did not reach a decision about some document, so "nothing was
75
+ # rejected" is not evidence that nothing was wrong.
76
+ Screening = Struct.new(:kept, :rejected, :certain, :reason, keyword_init: true) do
77
+ def certain?
78
+ certain
79
+ end
80
+
81
+ def rejected?
82
+ !rejected.empty?
83
+ end
84
+
85
+ def to_h
86
+ {
87
+ 'kept' => kept.size,
88
+ 'rejected' => rejected.map { |r| r[:result].to_h },
89
+ 'certain' => certain?,
90
+ 'reason' => reason
91
+ }.compact
92
+ end
93
+ end
94
+
95
+ def rails(side)
96
+ case side.to_sym
97
+ when :input then input_rails
98
+ when :context then context_rails
99
+ else output_rails
100
+ end
101
+ end
102
+
103
+ def rail_names(side)
104
+ rails(side).map(&:name)
105
+ end
106
+
107
+ # True when every configured rail decides without a network call, which is
108
+ # the only case where an unreachable endpoint cannot weaken the check.
109
+ def offline?
110
+ all = input_rails + context_rails + output_rails
111
+ !all.empty? && all.all?(&:offline?)
112
+ end
113
+
114
+ def empty?
115
+ input_rails.empty? && context_rails.empty? && output_rails.empty?
116
+ end
117
+
118
+ def to_h
119
+ {
120
+ 'input' => rail_names(:input),
121
+ 'context' => (rail_names(:context) unless context_rails.empty?),
122
+ 'output' => rail_names(:output),
123
+ 'on_error' => on_error.to_s,
124
+ 'offline' => offline?,
125
+ 'cache' => cache&.to_h
126
+ }.compact
127
+ end
128
+
129
+ def describe
130
+ return 'no rails' if empty?
131
+
132
+ parts = []
133
+ parts << "input=#{rail_names(:input).join('+')}" unless input_rails.empty?
134
+ parts << "context=#{rail_names(:context).join('+')}" unless context_rails.empty?
135
+ parts << "output=#{rail_names(:output).join('+')}" unless output_rails.empty?
136
+ parts << "on_error=#{on_error}"
137
+ parts << 'offline' if offline?
138
+ parts.join(' ')
139
+ end
140
+
141
+ private
142
+
143
+ def text_of(document)
144
+ return document.to_s unless document.is_a?(Hash)
145
+
146
+ (document['text'] || document[:text]).to_s
147
+ end
148
+
149
+ # A context rail may rewrite a document rather than reject it, so the
150
+ # replacement has to go back into the shape the caller passed in.
151
+ def replace_text(document, content)
152
+ return content.to_s unless document.is_a?(Hash)
153
+
154
+ key = document.key?('text') ? 'text' : :text
155
+ document.merge(key => content.to_s)
156
+ end
157
+
158
+ def run(side, rails, text, context)
159
+ return Result.unchecked(rail: side, reason: "no #{side} rails configured") if rails.empty?
160
+
161
+ ctx = context.merge(side: side)
162
+ current = text.to_s
163
+ modified_by = nil
164
+ uncertain = nil
165
+ unbuilt = nil
166
+
167
+ rails.each do |rail|
168
+ next unless rail.applies_to?(side)
169
+
170
+ result = invoke(rail, current, ctx)
171
+ return result.with_rail(rail.name) if result.blocked?
172
+
173
+ if result.modified?
174
+ current = result.content_or(current)
175
+ modified_by = rail.name
176
+ end
177
+ next if result.certain?
178
+
179
+ # A rail that ran and could not decide says more than one that was
180
+ # never built, so its reason is the one the caller sees. Without this,
181
+ # a placeholder earlier in the list reports "no endpoint was resolved"
182
+ # over the rail that actually tried and had the connection refused.
183
+ if rail.placeholder?
184
+ unbuilt ||= result
185
+ else
186
+ uncertain ||= result
187
+ end
188
+ end
189
+
190
+ finish(side, current, modified_by, uncertain || unbuilt)
191
+ end
192
+
193
+ def finish(side, current, modified_by, uncertain)
194
+ if modified_by
195
+ return Result.modified(rail: modified_by, content: current,
196
+ certain: uncertain.nil?, reason: uncertain&.reason)
197
+ end
198
+ return Result.unchecked(rail: uncertain.rail || side, reason: uncertain.reason) if uncertain
199
+
200
+ Result.passed(rail: side)
201
+ end
202
+
203
+ def invoke(rail, text, ctx)
204
+ memoized(rail, text, ctx) { call_rail(rail, text, ctx) }
205
+ end
206
+
207
+ def call_rail(rail, text, ctx)
208
+ result = rail.call(text, ctx)
209
+ return result if result.is_a?(Result)
210
+
211
+ raise ProtocolError, "#{rail.name} returned #{result.class}, expected Vangrail::Result"
212
+ rescue Error => e
213
+ failed(rail, e)
214
+ end
215
+
216
+ # A rail that raised did not answer. Which way that falls is the operator's
217
+ # call, and either way the pass carries the reason rather than a silence.
218
+ def failed(rail, error)
219
+ reason = "#{rail.name} failed: #{error.class.name.split('::').last}: #{error.message}"
220
+ if on_error == :block
221
+ return Result.new(status: :blocked, rail: rail.name, certain: false,
222
+ reason: reason)
223
+ end
224
+
225
+ Result.unchecked(rail: rail.name, reason: reason)
226
+ end
227
+
228
+ # Cache keys carry everything the decision depends on. A rail says what that
229
+ # is through `cache_key`; nil means the rail is not memoizable, which is the
230
+ # right answer for anything reading passages or history.
231
+ def memoized(rail, text, ctx, &block)
232
+ return block.call unless cache
233
+
234
+ key = rail.respond_to?(:cache_key) ? rail.cache_key(text, ctx) : nil
235
+ return block.call if key.nil?
236
+
237
+ cache.fetch(ctx[:side], rail.name, key, &block)
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vangrail
4
+ # Base for everything this gem raises, so a caller can rescue one class.
5
+ class Error < StandardError; end
6
+
7
+ # The endpoint answered, but not with something this client understands.
8
+ class ProtocolError < Error; end
9
+
10
+ # Transport failed: connect refused, TLS, timeout, DNS.
11
+ class TransportError < Error
12
+ attr_reader :cause_class
13
+
14
+ def initialize(message, cause_class: nil)
15
+ super(message)
16
+ @cause_class = cause_class
17
+ end
18
+ end
19
+
20
+ # A 4xx or 5xx with the body kept for the operator.
21
+ class HTTPError < Error
22
+ attr_reader :status, :body
23
+
24
+ def initialize(status, body)
25
+ @status = status
26
+ @body = body.to_s
27
+ super("HTTP #{status}: #{@body[0, 400]}")
28
+ end
29
+
30
+ def retryable?
31
+ status >= 500 || status == 429
32
+ end
33
+ end
34
+
35
+ # No credential resolved for an endpoint that needs one.
36
+ class MissingToken < Error; end
37
+
38
+ # A Colang file used something outside the supported subset, or is malformed.
39
+ # Raised at load, never at run: a rail configuration that half-loads would
40
+ # report checks it is not performing.
41
+ class ColangError < Error; end
42
+
43
+ # A flow referred to an action or a bot message that nothing defines.
44
+ class UnknownAction < ColangError; end
45
+
46
+ # The configuration folder is missing something the rails it declares need.
47
+ class ConfigError < Error; end
48
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module Vangrail
8
+ # JSON over Net::HTTP with stdlib only: a guardrail that pulls in a transport
9
+ # stack is a guardrail nobody installs. Every call is bounded by open and read
10
+ # timeouts, because a rail that hangs is worse than a rail that is absent.
11
+ class HTTP
12
+ DEFAULT_OPEN_TIMEOUT = 5
13
+ DEFAULT_READ_TIMEOUT = 30
14
+
15
+ attr_reader :base_url, :open_timeout, :read_timeout, :retries
16
+
17
+ def initialize(base_url:, api_key: nil, open_timeout: DEFAULT_OPEN_TIMEOUT,
18
+ read_timeout: DEFAULT_READ_TIMEOUT, retries: 1, headers: {})
19
+ @base_url = base_url.to_s.sub(/\/+\z/, '')
20
+ raise ArgumentError, 'base_url is required' if @base_url.empty?
21
+
22
+ @api_key = api_key
23
+ @open_timeout = open_timeout
24
+ @read_timeout = read_timeout
25
+ @retries = retries
26
+ @headers = headers
27
+ end
28
+
29
+ def get_json(path)
30
+ request(Net::HTTP::Get, path, nil)
31
+ end
32
+
33
+ def post_json(path, payload)
34
+ request(Net::HTTP::Post, path, payload)
35
+ end
36
+
37
+ # True when the endpoint answers at all. Used to pick a rail mode without
38
+ # making the caller handle an exception for the ordinary "not running" case.
39
+ def reachable?(path)
40
+ get_json(path)
41
+ true
42
+ rescue HTTPError
43
+ true
44
+ rescue Error
45
+ false
46
+ end
47
+
48
+ private
49
+
50
+ def request(klass, path, payload)
51
+ uri = URI.join("#{base_url}/", path.to_s.sub(/\A\/+/, ''))
52
+ attempt = 0
53
+ begin
54
+ attempt += 1
55
+ perform(klass, uri, payload)
56
+ rescue TransportError, HTTPError => e
57
+ raise unless attempt <= retries && retryable?(e)
58
+
59
+ sleep(0.25 * attempt)
60
+ retry
61
+ end
62
+ end
63
+
64
+ def retryable?(error)
65
+ error.is_a?(TransportError) || error.retryable?
66
+ end
67
+
68
+ def perform(klass, uri, payload)
69
+ req = klass.new(uri)
70
+ req['Accept'] = 'application/json'
71
+ req['Authorization'] = "Bearer #{@api_key}" if @api_key
72
+ @headers.each { |k, v| req[k] = v }
73
+ if payload
74
+ req['Content-Type'] = 'application/json'
75
+ req.body = JSON.generate(payload)
76
+ end
77
+
78
+ res = Net::HTTP.start(
79
+ uri.hostname, uri.port,
80
+ use_ssl: uri.scheme == 'https',
81
+ open_timeout: open_timeout,
82
+ read_timeout: read_timeout
83
+ ) { |http| http.request(req) }
84
+
85
+ code = res.code.to_i
86
+ raise HTTPError.new(code, res.body) unless code.between?(200, 299)
87
+
88
+ parse(res.body)
89
+ rescue *transport_errors => e
90
+ raise TransportError.new("#{uri.host}:#{uri.port} #{e.message}", cause_class: e.class.name)
91
+ end
92
+
93
+ def transport_errors
94
+ [
95
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ECONNRESET,
96
+ Net::OpenTimeout, Net::ReadTimeout, SocketError, IOError, EOFError
97
+ ]
98
+ end
99
+
100
+ def parse(body)
101
+ text = body.to_s
102
+ return {} if text.strip.empty?
103
+
104
+ JSON.parse(text)
105
+ rescue JSON::ParserError => e
106
+ raise ProtocolError, "endpoint returned non-JSON: #{e.message}"
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Vangrail
6
+ # Readers for what guard and judge models actually answer.
7
+ #
8
+ # Each returns a hash: {decided:, violated:, categories:, reason:}. `decided`
9
+ # false means the text was not in a form this code understands, which a rail
10
+ # turns into an uncertain result rather than a pass. Guessing at an unreadable
11
+ # answer is how a guardrail comes to report checks it never made.
12
+ module Parsers
13
+ module_function
14
+
15
+ # Llama Guard 3 hazard codes, the MLCommons taxonomy the model card lists.
16
+ LLAMA_GUARD_CATEGORIES = {
17
+ 'S1' => 'Violent Crimes',
18
+ 'S2' => 'Non-Violent Crimes',
19
+ 'S3' => 'Sex-Related Crimes',
20
+ 'S4' => 'Child Sexual Exploitation',
21
+ 'S5' => 'Defamation',
22
+ 'S6' => 'Specialized Advice',
23
+ 'S7' => 'Privacy',
24
+ 'S8' => 'Intellectual Property',
25
+ 'S9' => 'Indiscriminate Weapons',
26
+ 'S10' => 'Hate',
27
+ 'S11' => 'Suicide & Self-Harm',
28
+ 'S12' => 'Sexual Content',
29
+ 'S13' => 'Elections',
30
+ 'S14' => 'Code Interpreter Abuse'
31
+ }.freeze
32
+
33
+ # "safe" or "unsafe\nS1,S10".
34
+ def llama_guard(text)
35
+ lines = clean_lines(text)
36
+ head = lines.first.to_s.downcase
37
+ return undecided(text) unless head.start_with?('safe', 'unsafe')
38
+ return clean if head.start_with?('safe')
39
+
40
+ codes = codes_in(lines[1..]&.join(','), /S\d{1,2}/i)
41
+ { decided: true, violated: true, categories: codes,
42
+ reason: describe(codes, LLAMA_GUARD_CATEGORIES, 'unsafe') }
43
+ end
44
+
45
+ # "safe\nnon_adversarial" or "unsafe-O14,O12\nadversarial". Either line can
46
+ # condemn the turn: a jailbreak attempt with no hazard category is still one.
47
+ # With reasoning on the same two verdicts arrive as labelled fields after
48
+ # their assessments, so that form is tried first.
49
+ def apriel_guard(text)
50
+ reasoned = apriel_guard_reasoned(text)
51
+ return reasoned if reasoned
52
+
53
+ lines = clean_lines(text)
54
+ safety = lines.find { |l| l.match?(/\A(safe|unsafe)/i) }
55
+ adversarial = lines.find { |l| l.match?(/\A(non_adversarial|adversarial)/i) }
56
+ return undecided(text) if safety.nil? && adversarial.nil?
57
+
58
+ unsafe = safety.to_s.match?(/\Aunsafe/i)
59
+ attack = adversarial.to_s.match?(/\Aadversarial/i)
60
+ return clean unless unsafe || attack
61
+
62
+ codes = codes_in(safety, /O\d{1,2}/i)
63
+ codes += ['adversarial'] if attack
64
+ reason = unsafe ? describe(codes - ['adversarial'], {}, 'unsafe') : 'adversarial input'
65
+ { decided: true, violated: true, categories: codes, reason: reason }
66
+ end
67
+
68
+ # Reasoning mode:
69
+ #
70
+ # safety_risks_assessment_reasoning: ## Step 1 ...
71
+ # safety_risks_class: unsafe,
72
+ # safety_risks_categories: ['O15'],
73
+ # adversarial_attacks_assessment_reasoning: ## Step 1 ...
74
+ # adversarial_attacks_class: adversarial
75
+ #
76
+ # nil when the text is not in this form, so the caller can try the short one.
77
+ def apriel_guard_reasoned(text)
78
+ body = text.to_s
79
+ return nil unless body.include?('safety_risks_class')
80
+
81
+ fields = {}
82
+ body.each_line do |line|
83
+ m = line.chomp.match(
84
+ /\A(safety_risks_class|safety_risks_categories|adversarial_attacks_class)\s*:\s*(.*)\z/
85
+ )
86
+ fields[m[1]] = m[2].strip.sub(/,\z/, '') if m
87
+ end
88
+ unsafe = fields['safety_risks_class'].to_s.match?(/unsafe/i)
89
+ attack = fields['adversarial_attacks_class'].to_s.match?(/\Aadversarial/i)
90
+ return clean unless unsafe || attack
91
+
92
+ codes = codes_in(fields['safety_risks_categories'], /O\d{1,2}/i)
93
+ codes += ['adversarial'] if attack
94
+ block = unsafe ? 'safety_risks' : 'adversarial_attacks'
95
+ { decided: true, violated: true, categories: codes, reason: rationale(body, block) }
96
+ end
97
+
98
+ # A JSON verdict first, then a bare 0/1, then Yes/No. All three appear
99
+ # depending on which answer contract a policy prompt asked for.
100
+ def policy(text)
101
+ body = text.to_s
102
+ from_json = policy_json(body)
103
+ return from_json if from_json
104
+
105
+ stripped = body.strip
106
+ return (stripped.start_with?('0') ? clean : violation) if stripped.match?(/\A[01]\b/)
107
+
108
+ case stripped
109
+ when /\Ayes\b/i then violation(reason: 'policy judge said yes')
110
+ when /\Ano\b/i then clean
111
+ else undecided(body)
112
+ end
113
+ end
114
+
115
+ def policy_json(body)
116
+ obj = first_json_object(body)
117
+ return nil unless obj
118
+
119
+ value = obj['violation']
120
+ return nil unless [0, 1, true, false, '0', '1'].include?(value)
121
+ return clean unless [1, true, '1'].include?(value)
122
+
123
+ cats = [obj['policy_category'], *Array(obj['rule_ids'])].compact.map(&:to_s).reject(&:empty?)
124
+ violation(categories: cats, reason: (obj['rationale'] || cats.join(',')).to_s[0, 240])
125
+ end
126
+
127
+ # First balanced {...}, so a fenced or prefaced verdict still reads.
128
+ def first_json_object(text)
129
+ start = text.index('{')
130
+ return nil unless start
131
+
132
+ depth = 0
133
+ text[start..].each_char.with_index do |ch, i|
134
+ depth += 1 if ch == '{'
135
+ next unless ch == '}'
136
+
137
+ depth -= 1
138
+ return JSON.parse(text[start, i + 1]) if depth.zero?
139
+ end
140
+ nil
141
+ rescue JSON::ParserError
142
+ nil
143
+ end
144
+
145
+ # Last step of an assessment block, where the model states its conclusion
146
+ # rather than restating the input.
147
+ def rationale(body, block)
148
+ section = body[/#{block}_assessment_reasoning:(.*?)(?=^[a-z_]+:)/m, 1]
149
+ return nil unless section
150
+
151
+ steps = section.split(/^##\s*Step\s*\d+\s*$/m).map(&:strip).reject(&:empty?)
152
+ (steps.last || section).gsub(/\s+/, ' ').strip[0, 240]
153
+ end
154
+
155
+ def clean
156
+ { decided: true, violated: false, categories: [], reason: nil }
157
+ end
158
+
159
+ def violation(categories: [], reason: nil)
160
+ { decided: true, violated: true, categories: categories, reason: reason }
161
+ end
162
+
163
+ def undecided(text)
164
+ { decided: false, violated: false, categories: [], reason: text.to_s.strip[0, 120] }
165
+ end
166
+
167
+ def clean_lines(text)
168
+ text.to_s.strip.lines.map(&:strip).reject(&:empty?)
169
+ end
170
+
171
+ def codes_in(text, pattern)
172
+ text.to_s.scan(pattern).map(&:upcase).uniq
173
+ end
174
+
175
+ def describe(codes, names, fallback)
176
+ return fallback if codes.empty?
177
+
178
+ codes.map { |c| names[c] ? "#{c} #{names[c]}" : c }.join(', ')
179
+ end
180
+ end
181
+ end