vangrail 0.1.0 → 0.2.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/README.md +655 -43
- data/lib/vangrail/actions.rb +10 -3
- data/lib/vangrail/assessor.rb +249 -0
- data/lib/vangrail/bayes_data.rb +340 -0
- data/lib/vangrail/beta.rb +102 -0
- data/lib/vangrail/builder.rb +354 -0
- data/lib/vangrail/chat.rb +17 -15
- data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
- data/lib/vangrail/client.rb +27 -18
- data/lib/vangrail/colang/ast.rb +29 -3
- data/lib/vangrail/colang/interpreter.rb +55 -31
- data/lib/vangrail/colang/parser.rb +19 -61
- data/lib/vangrail/colang/value_parser.rb +161 -0
- data/lib/vangrail/completion.rb +86 -0
- data/lib/vangrail/config.rb +35 -15
- data/lib/vangrail/conversation.rb +240 -11
- data/lib/vangrail/dojo.rb +126 -0
- data/lib/vangrail/embeddings.rb +87 -0
- data/lib/vangrail/engine.rb +29 -70
- data/lib/vangrail/errors.rb +6 -1
- data/lib/vangrail/evidence.rb +303 -0
- data/lib/vangrail/evidence_data.rb +113 -0
- data/lib/vangrail/http.rb +18 -13
- data/lib/vangrail/judgement.rb +151 -0
- data/lib/vangrail/known_attacks.rb +45 -0
- data/lib/vangrail/linear_model.rb +124 -0
- data/lib/vangrail/nlp.rb +596 -0
- data/lib/vangrail/origin.rb +249 -0
- data/lib/vangrail/parsers.rb +5 -5
- data/lib/vangrail/profile.rb +114 -0
- data/lib/vangrail/prompt.rb +14 -3
- data/lib/vangrail/provider.rb +106 -75
- data/lib/vangrail/providers/gateway.rb +15 -14
- data/lib/vangrail/providers/llmlite.rb +25 -10
- data/lib/vangrail/providers.rb +6 -8
- data/lib/vangrail/rail.rb +46 -8
- data/lib/vangrail/rails/alignment.rb +91 -0
- data/lib/vangrail/rails/bayes.rb +115 -0
- data/lib/vangrail/rails/budget.rb +2 -2
- data/lib/vangrail/rails/canary.rb +2 -2
- data/lib/vangrail/rails/colang_flow.rb +9 -1
- data/lib/vangrail/rails/escalation.rb +15 -8
- data/lib/vangrail/rails/exfiltration.rb +2 -2
- data/lib/vangrail/rails/grounding.rb +8 -5
- data/lib/vangrail/rails/guard_model.rb +7 -4
- data/lib/vangrail/rails/hidden.rb +52 -9
- data/lib/vangrail/rails/injected_instructions.rb +29 -9
- data/lib/vangrail/rails/jailbreak.rb +2 -6
- data/lib/vangrail/rails/known_answer.rb +6 -2
- data/lib/vangrail/rails/language.rb +87 -0
- data/lib/vangrail/rails/linear.rb +80 -0
- data/lib/vangrail/rails/many_shot.rb +2 -6
- data/lib/vangrail/rails/markup.rb +3 -3
- data/lib/vangrail/rails/missing.rb +1 -5
- data/lib/vangrail/rails/obfuscation.rb +81 -13
- data/lib/vangrail/rails/paraphrase.rb +189 -0
- data/lib/vangrail/rails/pattern.rb +2 -6
- data/lib/vangrail/rails/perplexity.rb +100 -0
- data/lib/vangrail/rails/personal_data.rb +41 -9
- data/lib/vangrail/rails/prompt_leak.rb +132 -0
- data/lib/vangrail/rails/remote.rb +5 -1
- data/lib/vangrail/rails/secrets.rb +2 -2
- data/lib/vangrail/rails/self_check.rb +9 -6
- data/lib/vangrail/rails/semantic.rb +132 -0
- data/lib/vangrail/rails/similarity.rb +96 -0
- data/lib/vangrail/rails/trajectory.rb +10 -5
- data/lib/vangrail/result.rb +3 -3
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/screening.rb +68 -0
- data/lib/vangrail/session.rb +365 -0
- data/lib/vangrail/spotlight.rb +48 -8
- data/lib/vangrail/stream_guard.rb +8 -6
- data/lib/vangrail/tools.rb +58 -0
- data/lib/vangrail/version.rb +1 -1
- data/lib/vangrail.rb +39 -258
- metadata +34 -5
|
@@ -44,6 +44,10 @@ module Vangrail
|
|
|
44
44
|
@chat = chat || build_chat(provider, max_tokens, chat_options)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def offline?
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
47
51
|
# The verdict depends on the text and, on the output side, on the user
|
|
48
52
|
# turn sent with it.
|
|
49
53
|
def cache_key(text, context)
|
|
@@ -52,13 +56,12 @@ module Vangrail
|
|
|
52
56
|
"#{context[:user_input]} #{text}"
|
|
53
57
|
end
|
|
54
58
|
|
|
55
|
-
def
|
|
59
|
+
def decide(text, context)
|
|
56
60
|
answer = chat.ask(messages_for(text, context))
|
|
57
61
|
parsed = preset == :apriel_guard ? Parsers.apriel_guard(answer.text) : Parsers.llama_guard(answer.text)
|
|
58
62
|
unless parsed[:decided]
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
reason: "unparsed guard response: #{parsed[:reason]}")
|
|
63
|
+
return unchecked("unparsed guard response: #{parsed[:reason]}",
|
|
64
|
+
model: model, latency_ms: answer.latency_ms, raw: answer.raw)
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
return pass(model: model, latency_ms: answer.latency_ms, raw: answer.raw) unless parsed[:violated]
|
|
@@ -25,7 +25,8 @@ module Vangrail
|
|
|
25
25
|
# found rather than what it says. A hidden span with ordinary content in it
|
|
26
26
|
# passes: pages carry meta descriptions and alt text for good reasons, and
|
|
27
27
|
# a rail that objected to invisible text as such would reject most of the
|
|
28
|
-
# web.
|
|
28
|
+
# web. A child that rewrites a span (a key in a comment) replaces that
|
|
29
|
+
# span inside the page. The rest of the page is still the page.
|
|
29
30
|
#
|
|
30
31
|
# Only useful where documents arrive as HTML. A retrieval step that
|
|
31
32
|
# converts to markdown before storing has usually dropped most of these
|
|
@@ -57,7 +58,7 @@ module Vangrail
|
|
|
57
58
|
'hidden_attribute' => /<(\w+)\b[^>]*\bhidden\b[^>]*>(.*?)<\/\1>/mi,
|
|
58
59
|
# Markdown carries two of its own: a link title, and image alt text.
|
|
59
60
|
'link_title' => /\[[^\]]*\]\([^)\s]+\s+["']([^"']{12,})["']\)/,
|
|
60
|
-
'image_alt' => /!\[([^\]]{12,})\]\(
|
|
61
|
+
'image_alt' => /!\[([^\]]{12,})\]\(/,
|
|
61
62
|
}.freeze
|
|
62
63
|
|
|
63
64
|
attr_reader :rails, :carriers
|
|
@@ -76,16 +77,46 @@ module Vangrail
|
|
|
76
77
|
text if offline?
|
|
77
78
|
end
|
|
78
79
|
|
|
79
|
-
def
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
next unless result.blocked?
|
|
80
|
+
def decide(text, context)
|
|
81
|
+
rewritten = text.to_s
|
|
82
|
+
modified = nil
|
|
83
|
+
uncertain = nil
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
blocked = catch(:blocked) do
|
|
86
|
+
carriers.each do |carrier, pattern|
|
|
87
|
+
rewritten = rewritten.gsub(pattern) do |full|
|
|
88
|
+
span = captured_span(Regexp.last_match)
|
|
89
|
+
next full if span.empty?
|
|
90
|
+
|
|
91
|
+
current = span
|
|
92
|
+
rails.each do |rail|
|
|
93
|
+
result = rail.call(current, context)
|
|
94
|
+
extra = ["hidden:#{carrier}"]
|
|
95
|
+
if result.blocked?
|
|
96
|
+
throw :blocked, wrapped_block(result, extra, carrier)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if result.modified?
|
|
100
|
+
current = result.content.to_s
|
|
101
|
+
modified = [result, extra, carrier]
|
|
102
|
+
elsif !result.certain?
|
|
103
|
+
uncertain ||= result
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
current == span ? full : full.sub(span) { current }
|
|
107
|
+
end
|
|
87
108
|
end
|
|
109
|
+
nil
|
|
88
110
|
end
|
|
111
|
+
return blocked if blocked
|
|
112
|
+
|
|
113
|
+
if modified
|
|
114
|
+
result, extra, carrier = modified
|
|
115
|
+
return modify(rewritten, categories: (result.categories || []) + extra,
|
|
116
|
+
reason: "#{result.reason} (hidden in #{carrier.tr('_', ' ')})")
|
|
117
|
+
end
|
|
118
|
+
return unchecked(uncertain.reason) if uncertain
|
|
119
|
+
|
|
89
120
|
pass
|
|
90
121
|
end
|
|
91
122
|
|
|
@@ -100,6 +131,18 @@ module Vangrail
|
|
|
100
131
|
end
|
|
101
132
|
end
|
|
102
133
|
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def captured_span(match)
|
|
138
|
+
captures = match.captures.compact
|
|
139
|
+
(captures.max_by(&:length) || match[0]).to_s.strip
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def wrapped_block(result, extra, carrier)
|
|
143
|
+
block(categories: (result.categories || []) + extra,
|
|
144
|
+
reason: "#{result.reason} (hidden in #{carrier.tr('_', ' ')})")
|
|
145
|
+
end
|
|
103
146
|
end
|
|
104
147
|
end
|
|
105
148
|
end
|
|
@@ -25,10 +25,33 @@ module Vangrail
|
|
|
25
25
|
class InjectedInstructions < Rail
|
|
26
26
|
PATTERNS = {
|
|
27
27
|
# A chat role header inside a document: nothing in prose needs one.
|
|
28
|
-
|
|
28
|
+
# The Dutch role names sit beside the English ones because a role
|
|
29
|
+
# header is a shape rather than a sentence, and a page that writes
|
|
30
|
+
# "assistent:" mid-paragraph is doing what "assistant:" does.
|
|
31
|
+
'role_header' => /^\s{0,3}(?:###\s*)?(?:system|systeem|assistant|assistent|user|gebruiker)\s*:\s*\S/i,
|
|
29
32
|
# An override aimed at whatever came before it in the prompt.
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
#
|
|
34
|
+
# Three branches rather than one alternation, because a backward
|
|
35
|
+
# reference on its own is not an attack. "You can ignore the earlier
|
|
36
|
+
# warning about the toolchain" is a page, and it fell to the single
|
|
37
|
+
# alternation: the override has to name what it cancels, cancel the lot,
|
|
38
|
+
# or use the reference as a noun.
|
|
39
|
+
'override_above' => /
|
|
40
|
+
\b(?:ignore|disregard|forget|override)\b
|
|
41
|
+
(?:
|
|
42
|
+
# Names the standing instruction, in any of its usual words.
|
|
43
|
+
[^.\n]{0,40}\b(?:instructions?|guidelines?|rules?|directions?|directives?|
|
|
44
|
+
system\s+prompt|everything\s+above)\b
|
|
45
|
+
|
|
|
46
|
+
# Cancels all of it, which is what an attacker does when they do
|
|
47
|
+
# not know what they are cancelling.
|
|
48
|
+
\s+(?:all|any|every|everything|anything)\b[^.\n]{0,20}
|
|
49
|
+
\b(?:above|previous|prior|earlier|preceding)\b
|
|
50
|
+
|
|
|
51
|
+
# The reference used as a noun and nothing after it.
|
|
52
|
+
\s+(?:the\s+)?(?:above|preceding|foregoing)\s*[.,;:]?\s*$
|
|
53
|
+
)
|
|
54
|
+
/xi,
|
|
32
55
|
# A document telling the assistant what it is now.
|
|
33
56
|
'role_assignment' => /\byou\s+(?:are|must\s+now\s+act|will\s+now\s+act)\s+(?:now\s+)?
|
|
34
57
|
(?:a|an|the)?\s*(?:assistant|ai|model|chatbot|dan)\b/xi,
|
|
@@ -56,7 +79,8 @@ module Vangrail
|
|
|
56
79
|
(?:system\s+prompt|api[_\s-]?key|token|credentials?|conversation)\b/xi,
|
|
57
80
|
# Hidden text: a marker for content meant for the model and not the
|
|
58
81
|
# reader. HTML comments in a rendered page are the common carrier.
|
|
59
|
-
'hidden_directive' => /<!--[^>]*\b(?:ignore|instruction|assistant|system|prompt
|
|
82
|
+
'hidden_directive' => /<!--[^>]*\b(?:ignore|instruction|assistant|system|prompt|
|
|
83
|
+
negeer|instructie|assistent|systeem)\b[^>]*-->/imx,
|
|
60
84
|
}.freeze
|
|
61
85
|
|
|
62
86
|
attr_reader :patterns
|
|
@@ -66,15 +90,11 @@ module Vangrail
|
|
|
66
90
|
@patterns = patterns
|
|
67
91
|
end
|
|
68
92
|
|
|
69
|
-
def offline?
|
|
70
|
-
true
|
|
71
|
-
end
|
|
72
|
-
|
|
73
93
|
def cache_key(text, _context)
|
|
74
94
|
text
|
|
75
95
|
end
|
|
76
96
|
|
|
77
|
-
def
|
|
97
|
+
def decide(text, _context)
|
|
78
98
|
body = text.to_s
|
|
79
99
|
hits = patterns.select { |_label, pattern| pattern.match?(body) }.keys
|
|
80
100
|
return pass if hits.empty?
|
|
@@ -85,7 +85,7 @@ module Vangrail
|
|
|
85
85
|
|
|
|
86
86
|
\bthis\s+is\s+(?:the\s+)?[a-z][\w.-]*(?:\s+(?:ai|inc|labs?))?\b[^.\n]{0,30}
|
|
87
87
|
\byou\s+are\s+now\s+(?:permitted|allowed|required|authori[sz]ed)\b
|
|
88
|
-
/xi
|
|
88
|
+
/xi,
|
|
89
89
|
}.freeze
|
|
90
90
|
|
|
91
91
|
attr_reader :patterns
|
|
@@ -95,15 +95,11 @@ module Vangrail
|
|
|
95
95
|
@patterns = patterns
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
def offline?
|
|
99
|
-
true
|
|
100
|
-
end
|
|
101
|
-
|
|
102
98
|
def cache_key(text, _context)
|
|
103
99
|
text
|
|
104
100
|
end
|
|
105
101
|
|
|
106
|
-
def
|
|
102
|
+
def decide(text, _context)
|
|
107
103
|
hits = patterns.select { |_label, pattern| pattern.match?(text.to_s) }.keys
|
|
108
104
|
return pass if hits.empty?
|
|
109
105
|
|
|
@@ -76,13 +76,17 @@ module Vangrail
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
def offline?
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
79
83
|
# Never memoizable in the useful sense: the token changes per check, and
|
|
80
84
|
# a cached verdict would be a verdict about a different question.
|
|
81
85
|
def cache_key(_text, _context)
|
|
82
86
|
nil
|
|
83
87
|
end
|
|
84
88
|
|
|
85
|
-
def
|
|
89
|
+
def decide(text, _context)
|
|
86
90
|
body = text.to_s
|
|
87
91
|
return pass if body.strip.empty?
|
|
88
92
|
|
|
@@ -101,7 +105,7 @@ module Vangrail
|
|
|
101
105
|
def ask(token, body)
|
|
102
106
|
chat.ask([
|
|
103
107
|
{ 'role' => 'system', 'content' => format(INSTRUCTION, token: token) },
|
|
104
|
-
{ 'role' => 'user', 'content' => "<<<DOCUMENT\n#{body}\nDOCUMENT>>>" }
|
|
108
|
+
{ 'role' => 'user', 'content' => "<<<DOCUMENT\n#{body}\nDOCUMENT>>>" },
|
|
105
109
|
])
|
|
106
110
|
end
|
|
107
111
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../nlp'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# Reports that a page or a question is in a language nothing here can read.
|
|
9
|
+
#
|
|
10
|
+
# Every other deterministic rail in this gem is a rule about English or
|
|
11
|
+
# Dutch words. Handed a page in German, all of them return passed, and an
|
|
12
|
+
# application reading that result cannot tell "checked and clean" from "the
|
|
13
|
+
# checks do not apply to this text". That is precisely the distinction this
|
|
14
|
+
# gem promises to keep, so the promise has to hold across languages too,
|
|
15
|
+
# and on the question as well as on the retrieved page.
|
|
16
|
+
#
|
|
17
|
+
# So this rail never blocks. A page in an unsupported language is not an
|
|
18
|
+
# attack, and refusing it would break a site that has one; what it is, is
|
|
19
|
+
# unchecked, and `certain? == false` is the word for that. An application
|
|
20
|
+
# reporting a safety posture can then route the page to a model rail, to a
|
|
21
|
+
# human, or to nothing, knowing which it is doing.
|
|
22
|
+
#
|
|
23
|
+
# Below the floor it says nothing at all. A six-word question is not
|
|
24
|
+
# evidence of a language, and marking every short turn uncertain would turn
|
|
25
|
+
# the posture into noise, which is a different way of making it useless.
|
|
26
|
+
class Language < Rail
|
|
27
|
+
def initialize(supported: NLP::LANGUAGES, floor: NLP::LANGUAGE_FLOOR,
|
|
28
|
+
name: 'language', sides: %i[input context])
|
|
29
|
+
super(name: name, sides: sides)
|
|
30
|
+
@supported = Array(supported).map(&:to_sym)
|
|
31
|
+
@floor = floor
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :supported, :floor
|
|
35
|
+
|
|
36
|
+
def posture?
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cache_key(text, _context)
|
|
41
|
+
"#{supported.join('+')}\n#{text}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def decide(text, _context)
|
|
45
|
+
reason = unread_reason(text)
|
|
46
|
+
return unchecked(reason) if reason
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Clause by clause: a German paragraph inside an English page is
|
|
54
|
+
# unread even when the function words of the page as a whole are
|
|
55
|
+
# English. The twelve-token floor still applies per clause, so a
|
|
56
|
+
# short question stays quiet.
|
|
57
|
+
def unread_reason(text)
|
|
58
|
+
whole = classify(text)
|
|
59
|
+
return whole if whole
|
|
60
|
+
|
|
61
|
+
spans = NLP.clauses(text)
|
|
62
|
+
return nil if spans.size <= 1
|
|
63
|
+
|
|
64
|
+
spans.each do |span|
|
|
65
|
+
reason = classify(span)
|
|
66
|
+
return reason if reason
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def classify(span)
|
|
72
|
+
tokens = NLP.words(span)
|
|
73
|
+
return nil if tokens.size < floor
|
|
74
|
+
|
|
75
|
+
found = NLP.language(span)
|
|
76
|
+
return nil if supported.include?(found)
|
|
77
|
+
# Named unread (Cavnar-Trenkle) is evidence even in the
|
|
78
|
+
# twelve-to-twenty-three token band. An unidentified short
|
|
79
|
+
# handbook sentence is not.
|
|
80
|
+
return nil if tokens.size < (floor * 2) && !NLP.named_foreign?(span, supported)
|
|
81
|
+
|
|
82
|
+
"text is not in a language this engine reads (#{supported.join(', ')}); " \
|
|
83
|
+
'the deterministic rails do not apply to it'
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../linear_model'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# The fitted classifier, reading a model somebody trained on their own
|
|
9
|
+
# traffic.
|
|
10
|
+
#
|
|
11
|
+
# On the corpus this repository can measure, it is the best detector here by
|
|
12
|
+
# a wide margin and within two points of a published transformer:
|
|
13
|
+
#
|
|
14
|
+
# hand-written rails 39.5%
|
|
15
|
+
# naive Bayes over n-grams 67.0%
|
|
16
|
+
# this rail 73.7%
|
|
17
|
+
# deberta-v3-base-prompt-injection-v2 75.3%
|
|
18
|
+
#
|
|
19
|
+
# all at the same false-alarm rate, cross-validated where fitted. Below one
|
|
20
|
+
# false alarm in a hundred it beats the transformer, 27.0% against 21.1%,
|
|
21
|
+
# which is the operating point a desk actually wants and the one a rule
|
|
22
|
+
# stack cannot be asked about at all.
|
|
23
|
+
#
|
|
24
|
+
# It ships without a model, deliberately. Weights fitted on somebody else's
|
|
25
|
+
# traffic are the thing this repository spent a long time measuring the cost
|
|
26
|
+
# of, and the numbers above are for a model fitted on the corpus it was
|
|
27
|
+
# scored against. Fit your own:
|
|
28
|
+
#
|
|
29
|
+
# ruby script/train_linear.rb --emit model.json
|
|
30
|
+
#
|
|
31
|
+
# With no model configured the rail reports itself unchecked rather than
|
|
32
|
+
# passing, because a detector that is not there must not look like a
|
|
33
|
+
# detector that found nothing.
|
|
34
|
+
class Linear < Rail
|
|
35
|
+
def initialize(model: nil, path: nil, threshold: nil, name: 'linear', sides: %i[input context])
|
|
36
|
+
super(name: name, sides: sides)
|
|
37
|
+
@path = path
|
|
38
|
+
@model = model || load_model(path)
|
|
39
|
+
@threshold = threshold || @model&.threshold || 0.0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :model, :threshold
|
|
43
|
+
|
|
44
|
+
def cache_key(text, _context)
|
|
45
|
+
return nil unless model
|
|
46
|
+
|
|
47
|
+
"#{threshold}\n#{text}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def decide(text, _context)
|
|
51
|
+
return unchecked(missing_reason) unless model
|
|
52
|
+
|
|
53
|
+
value = model.score(text)
|
|
54
|
+
return pass if value <= threshold
|
|
55
|
+
|
|
56
|
+
block(categories: ['linear'],
|
|
57
|
+
reason: format('scores %<value>+.2f against a threshold of %<threshold>+.2f', value: value,
|
|
58
|
+
threshold: threshold))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def load_model(path)
|
|
64
|
+
path ||= ENV.fetch('GUARDRAILS_LINEAR_MODEL', nil)
|
|
65
|
+
return nil if path.nil? || path.to_s.strip.empty?
|
|
66
|
+
|
|
67
|
+
LinearModel.load(path)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
@load_error = "#{path}: #{e.class.name.split('::').last}: #{e.message}"
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def missing_reason
|
|
74
|
+
return "linear model could not be read (#{@load_error})" if @load_error
|
|
75
|
+
|
|
76
|
+
'no linear model configured: set GUARDRAILS_LINEAR_MODEL, or fit one with script/train_linear.rb'
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -43,7 +43,7 @@ module Vangrail
|
|
|
43
43
|
|
|
44
44
|
# A role header at the start of a line, which is how a pasted transcript
|
|
45
45
|
# is written when it is not using template tokens.
|
|
46
|
-
TURN = /^\s{0,3}(?:###\s*)?(?:system|user|human|assistant|ai|bot|q|a)\s*:\s*\S/i
|
|
46
|
+
TURN = /^\s{0,3}(?:###\s*)?(?:system|user|human|assistant|ai|bot|q|a|systeem|gebruiker|assistent|mens)\s*:\s*\S/i
|
|
47
47
|
|
|
48
48
|
attr_reader :max_turns, :placeholder
|
|
49
49
|
|
|
@@ -53,15 +53,11 @@ module Vangrail
|
|
|
53
53
|
@placeholder = placeholder
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
def offline?
|
|
57
|
-
true
|
|
58
|
-
end
|
|
59
|
-
|
|
60
56
|
def cache_key(text, _context)
|
|
61
57
|
text
|
|
62
58
|
end
|
|
63
59
|
|
|
64
|
-
def
|
|
60
|
+
def decide(text, _context)
|
|
65
61
|
body = text.to_s
|
|
66
62
|
turns = body.scan(TURN).size
|
|
67
63
|
if turns > max_turns
|
|
@@ -40,7 +40,7 @@ module Vangrail
|
|
|
40
40
|
'base_tag' => /<base\b[^>]*>/i,
|
|
41
41
|
'form' => /<form\b[^>]*>.*?<\/form>|<form\b[^>]*>/mi,
|
|
42
42
|
# Styling can position an invisible overlay over the page.
|
|
43
|
-
'style_block' => /<style\b[^>]*>.*?<\/style>/mi
|
|
43
|
+
'style_block' => /<style\b[^>]*>.*?<\/style>/mi,
|
|
44
44
|
}.freeze
|
|
45
45
|
|
|
46
46
|
attr_reader :patterns
|
|
@@ -50,7 +50,7 @@ module Vangrail
|
|
|
50
50
|
@patterns = patterns
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
def
|
|
53
|
+
def language_agnostic?
|
|
54
54
|
true
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -58,7 +58,7 @@ module Vangrail
|
|
|
58
58
|
text
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def
|
|
61
|
+
def decide(text, _context)
|
|
62
62
|
body = text.to_s
|
|
63
63
|
found = []
|
|
64
64
|
cleaned = patterns.reduce(body) do |acc, (label, pattern)|
|
|
@@ -26,7 +26,10 @@ module Vangrail
|
|
|
26
26
|
# original is dropped, so ordinary text costs one comparison per transform
|
|
27
27
|
# and nothing else. A hit names both the rail and the encoding it was
|
|
28
28
|
# hiding under, because "blocked" without that is unactionable for whoever
|
|
29
|
-
# has to look at the page.
|
|
29
|
+
# has to look at the page. A child that rewrites a variant (a key inside a
|
|
30
|
+
# decoded blob) is spliced back into the page, or into that variant when
|
|
31
|
+
# the variant *is* the page (the invisible-character strip). The decoded
|
|
32
|
+
# form is not published in place of the document.
|
|
30
33
|
#
|
|
31
34
|
# Invisible characters are handled here directly rather than by a delegate:
|
|
32
35
|
# they are stripped, and the strip is reported as a rewrite. A zero-width
|
|
@@ -41,7 +44,12 @@ module Vangrail
|
|
|
41
44
|
# Zero-width and bidi control characters. The first four are the invisible
|
|
42
45
|
# separators; the bidi set is the trojan-source family, where the rendered
|
|
43
46
|
# order and the stored order disagree.
|
|
44
|
-
|
|
47
|
+
# The last one is the replacement character, which is what a scrub leaves
|
|
48
|
+
# where an invalid byte was. Garbage bytes inside a keyword are the same
|
|
49
|
+
# move as a zero-width joiner with a cruder tool: they break a pattern
|
|
50
|
+
# without changing what a model reads, and scrubbing restores validity
|
|
51
|
+
# rather than the phrase. Legitimate text does not carry them.
|
|
52
|
+
INVISIBLE = /[---�]/
|
|
45
53
|
|
|
46
54
|
# A base64 run long enough to hold a sentence. Below this the decode is
|
|
47
55
|
# noise, and a handbook is full of short tokens that happen to be in the
|
|
@@ -73,12 +81,13 @@ module Vangrail
|
|
|
73
81
|
text if offline?
|
|
74
82
|
end
|
|
75
83
|
|
|
76
|
-
def
|
|
84
|
+
def decide(text, context)
|
|
77
85
|
body = text.to_s
|
|
78
86
|
stripped = body.gsub(INVISIBLE, '')
|
|
79
87
|
|
|
80
|
-
hit = first_objection(body, stripped, context)
|
|
88
|
+
hit, uncertain = first_objection(body, stripped, context)
|
|
81
89
|
return hit if hit
|
|
90
|
+
return unchecked(uncertain.reason) if uncertain
|
|
82
91
|
|
|
83
92
|
return pass if stripped == body
|
|
84
93
|
|
|
@@ -100,9 +109,9 @@ module Vangrail
|
|
|
100
109
|
|
|
101
110
|
private
|
|
102
111
|
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
112
|
+
# Walks every variant. A block stops the pass. A rewrite is spliced into
|
|
113
|
+
# the page and the remaining encodings are still read, so a redaction
|
|
114
|
+
# cannot hide a later injection.
|
|
106
115
|
def first_objection(body, stripped, context)
|
|
107
116
|
candidates = []
|
|
108
117
|
# The stripped text is a variant in its own right when anything was
|
|
@@ -111,16 +120,75 @@ module Vangrail
|
|
|
111
120
|
candidates << [:invisible, stripped] unless stripped == body
|
|
112
121
|
candidates.concat(variants(stripped))
|
|
113
122
|
|
|
123
|
+
published = stripped
|
|
124
|
+
modified = nil
|
|
125
|
+
uncertain = nil
|
|
114
126
|
candidates.each do |name, decoded|
|
|
127
|
+
viewed = decoded
|
|
128
|
+
variant_modified = nil
|
|
129
|
+
extra = ["encoded:#{name}"]
|
|
115
130
|
rails.each do |rail|
|
|
116
|
-
result = rail.call(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
result = rail.call(viewed, context)
|
|
132
|
+
if result.blocked?
|
|
133
|
+
return [block(categories: (result.categories || []) + extra,
|
|
134
|
+
reason: "#{result.reason} (hidden with #{name})"), nil]
|
|
135
|
+
end
|
|
136
|
+
if result.modified?
|
|
137
|
+
viewed = result.content.to_s
|
|
138
|
+
variant_modified = [result, extra, name]
|
|
139
|
+
elsif !result.certain?
|
|
140
|
+
uncertain ||= result
|
|
141
|
+
end
|
|
121
142
|
end
|
|
143
|
+
next if variant_modified.nil? || modified
|
|
144
|
+
|
|
145
|
+
published = apply_rewrite(published, name, decoded, viewed)
|
|
146
|
+
modified = variant_modified
|
|
122
147
|
end
|
|
123
|
-
|
|
148
|
+
if modified
|
|
149
|
+
result, extra, name = modified
|
|
150
|
+
return [modify(published, categories: (result.categories || []) + extra,
|
|
151
|
+
reason: "#{result.reason} (hidden with #{name})"), nil]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
[nil, uncertain]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Puts the child's rewrite back on the page the reader will see.
|
|
158
|
+
def apply_rewrite(published, name, decoded, rewrite)
|
|
159
|
+
return splice_base64(published, rewrite) if name == :base64
|
|
160
|
+
return rewrite if decoded == published
|
|
161
|
+
return published.sub(decoded) { rewrite } if published.include?(decoded)
|
|
162
|
+
return splice_aligned(published, decoded, rewrite) if published.length == decoded.length
|
|
163
|
+
|
|
164
|
+
published
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def splice_base64(published, rewrite)
|
|
168
|
+
runs = published.scan(BASE64)
|
|
169
|
+
return published if runs.empty?
|
|
170
|
+
return published.sub(runs.first) { rewrite } unless runs.size > 1
|
|
171
|
+
|
|
172
|
+
pieces = rewrite.split("\n")
|
|
173
|
+
return published.sub(runs.first) { rewrite } unless pieces.size == runs.size
|
|
174
|
+
|
|
175
|
+
runs.zip(pieces).reduce(published) { |acc, (run, piece)| acc.sub(run) { piece } }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def splice_aligned(published, decoded, rewrite)
|
|
179
|
+
prefix = 0
|
|
180
|
+
limit = [decoded.length, rewrite.length].min
|
|
181
|
+
prefix += 1 while prefix < limit && decoded[prefix] == rewrite[prefix]
|
|
182
|
+
|
|
183
|
+
suffix = 0
|
|
184
|
+
max_suffix = [decoded.length - prefix, rewrite.length - prefix].min
|
|
185
|
+
while suffix < max_suffix &&
|
|
186
|
+
decoded[decoded.length - 1 - suffix] == rewrite[rewrite.length - 1 - suffix]
|
|
187
|
+
suffix += 1
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
replacement = rewrite[prefix, rewrite.length - prefix - suffix]
|
|
191
|
+
published[0, prefix] + replacement.to_s + published[published.length - suffix, suffix]
|
|
124
192
|
end
|
|
125
193
|
|
|
126
194
|
def apply(name, body)
|