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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +473 -0
- data/lib/vangrail/actions.rb +61 -0
- data/lib/vangrail/chat.rb +88 -0
- data/lib/vangrail/client/completion.rb +122 -0
- data/lib/vangrail/client.rb +219 -0
- data/lib/vangrail/colang/ast.rb +53 -0
- data/lib/vangrail/colang/interpreter.rb +131 -0
- data/lib/vangrail/colang/library.rb +53 -0
- data/lib/vangrail/colang/parser.rb +222 -0
- data/lib/vangrail/config.rb +270 -0
- data/lib/vangrail/confusables.rb +67 -0
- data/lib/vangrail/confusables_data.rb +1673 -0
- data/lib/vangrail/conversation.rb +105 -0
- data/lib/vangrail/engine.rb +240 -0
- data/lib/vangrail/errors.rb +48 -0
- data/lib/vangrail/http.rb +109 -0
- data/lib/vangrail/parsers.rb +181 -0
- data/lib/vangrail/policies.rb +202 -0
- data/lib/vangrail/prompt.rb +88 -0
- data/lib/vangrail/provider.rb +191 -0
- data/lib/vangrail/providers/gateway.rb +131 -0
- data/lib/vangrail/providers/llmlite.rb +71 -0
- data/lib/vangrail/providers.rb +72 -0
- data/lib/vangrail/rail.rb +93 -0
- data/lib/vangrail/rails/budget.rb +63 -0
- data/lib/vangrail/rails/canary.rb +76 -0
- data/lib/vangrail/rails/colang_flow.rb +40 -0
- data/lib/vangrail/rails/escalation.rb +178 -0
- data/lib/vangrail/rails/exfiltration.rb +167 -0
- data/lib/vangrail/rails/grounding.rb +64 -0
- data/lib/vangrail/rails/guard_model.rb +96 -0
- data/lib/vangrail/rails/hidden.rb +105 -0
- data/lib/vangrail/rails/injected_instructions.rb +86 -0
- data/lib/vangrail/rails/jailbreak.rb +114 -0
- data/lib/vangrail/rails/known_answer.rb +118 -0
- data/lib/vangrail/rails/many_shot.rb +80 -0
- data/lib/vangrail/rails/markup.rb +77 -0
- data/lib/vangrail/rails/missing.rb +38 -0
- data/lib/vangrail/rails/obfuscation.rb +186 -0
- data/lib/vangrail/rails/pattern.rb +57 -0
- data/lib/vangrail/rails/personal_data.rb +152 -0
- data/lib/vangrail/rails/remote.rb +40 -0
- data/lib/vangrail/rails/secrets.rb +77 -0
- data/lib/vangrail/rails/self_check.rb +81 -0
- data/lib/vangrail/rails/trajectory.rb +101 -0
- data/lib/vangrail/result.rb +114 -0
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/spotlight.rb +157 -0
- data/lib/vangrail/stream_guard.rb +163 -0
- data/lib/vangrail/version.rb +5 -0
- data/lib/vangrail.rb +354 -0
- 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
|