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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../nlp'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# Catches the injection that was reworded.
|
|
9
|
+
#
|
|
10
|
+
# Rails::InjectedInstructions and Rails::Jailbreak match strings, and a
|
|
11
|
+
# string is what an attacker edits first. "Ignore all previous
|
|
12
|
+
# instructions" is one thesaurus away from "discard every guideline stated
|
|
13
|
+
# so far", which no pattern in this gem matches and which asks for exactly
|
|
14
|
+
# the same thing. That gap is stated plainly in the coverage page, and this
|
|
15
|
+
# rail is the part of it that can be closed without a model.
|
|
16
|
+
#
|
|
17
|
+
# The move is to stop matching words. The text is reduced to concepts
|
|
18
|
+
# first (NLP.concepts), and what gets matched is a pair of concepts close
|
|
19
|
+
# together: an override next to an instruction, a revealing verb next to a
|
|
20
|
+
# secret, concealment next to an audience, a persona next to a state with
|
|
21
|
+
# no rules in it. A rewording that keeps the meaning keeps the concepts,
|
|
22
|
+
# because the concepts are what the sentence is for.
|
|
23
|
+
#
|
|
24
|
+
# Two concepts rather than one, always. A document that mentions
|
|
25
|
+
# instructions is a handbook; a document that mentions overriding them is
|
|
26
|
+
# an attack. Every single-concept rule tried against the corpus flagged
|
|
27
|
+
# ordinary documentation, and the pair is what separates the two without a
|
|
28
|
+
# judgement call.
|
|
29
|
+
#
|
|
30
|
+
# Because concepts are language-independent, a second language costs a word
|
|
31
|
+
# list rather than a second rail. English and Dutch are both read by
|
|
32
|
+
# default: the pattern rails in this gem are English-only, so a Dutch wiki
|
|
33
|
+
# page is a page nothing else here can read, and at a Dutch institution
|
|
34
|
+
# that page is the ordinary case rather than the exotic one.
|
|
35
|
+
#
|
|
36
|
+
# The limits are worth being exact about, because this is the rail most
|
|
37
|
+
# likely to be mistaken for understanding. A synonym outside NLP::CONCEPTS
|
|
38
|
+
# is a miss. A sentence in another language is a miss. An attacker who
|
|
39
|
+
# reads this file can pick words that are in no list here, exactly as they
|
|
40
|
+
# can with the patterns. What it buys is that rewording no longer costs an
|
|
41
|
+
# attacker one edit, and that the cheap rewrites that beat a regexp do not
|
|
42
|
+
# beat this.
|
|
43
|
+
class Paraphrase < Rail
|
|
44
|
+
# Each entry is two concepts and how far apart they may sit, in tokens.
|
|
45
|
+
# The windows are short because closeness is the whole claim: two
|
|
46
|
+
# concepts in one clause are one statement, and the same two concepts
|
|
47
|
+
# nine lines apart are two unrelated sentences in a long page.
|
|
48
|
+
# Cancel the standing instruction, however it is named. Ordered, because
|
|
49
|
+
# an override governs its object: "ignore the guidelines" is an attack
|
|
50
|
+
# and "follow the guidance and ignore stale copies of it" has the same
|
|
51
|
+
# two concepts in the other order and is a handbook sentence.
|
|
52
|
+
TEMPLATES = [
|
|
53
|
+
{ label: 'instruction_override', concepts: %i[override instruction], window: 6, ordered: true },
|
|
54
|
+
# Cancel what came before without naming it. "Ignore the earlier
|
|
55
|
+
# warning" is a page; "dismiss anything stated earlier" is an attack,
|
|
56
|
+
# and the quantifier is the difference. An attacker cancels the lot
|
|
57
|
+
# because they do not know what they are cancelling.
|
|
58
|
+
{ label: 'instruction_override', concepts: %i[override prior], window: 4, ordered: true,
|
|
59
|
+
requires: %i[totality] },
|
|
60
|
+
# Move a secret out. The clause has to address the assistant: "print
|
|
61
|
+
# the configuration with scontrol" tells a reader to run a command,
|
|
62
|
+
# "print your configuration" talks to the thing reading the page.
|
|
63
|
+
{ label: 'prompt_disclosure', concepts: %i[reveal secret], window: 8, ordered: true,
|
|
64
|
+
requires: %i[self] },
|
|
65
|
+
# Keep something from the person reading the answer. Both halves are
|
|
66
|
+
# needed: concealment alone is security advice ("never mention a
|
|
67
|
+
# password in a ticket"), and an audience alone is every support page.
|
|
68
|
+
{ label: 'answer_shaping', concepts: %i[conceal audience], window: 10 },
|
|
69
|
+
{ label: 'answer_shaping', concepts: %i[conceal answering], window: 10 },
|
|
70
|
+
# Be something without rules.
|
|
71
|
+
{ label: 'unrestricted_persona', concepts: %i[persona unrestricted], window: 8 },
|
|
72
|
+
].freeze
|
|
73
|
+
|
|
74
|
+
attr_reader :templates, :languages
|
|
75
|
+
|
|
76
|
+
# Both languages by default. A deployment whose corpus is genuinely
|
|
77
|
+
# single-language can say so and pay a shorter lexicon; one that thinks
|
|
78
|
+
# it is single-language usually has a Dutch page in it somewhere, which
|
|
79
|
+
# is the case the default is for.
|
|
80
|
+
def initialize(templates: TEMPLATES, languages: NLP::LANGUAGES, name: 'paraphrase',
|
|
81
|
+
sides: %i[input context])
|
|
82
|
+
super(name: name, sides: sides)
|
|
83
|
+
@templates = templates
|
|
84
|
+
@languages = Array(languages).map(&:to_sym)
|
|
85
|
+
unknown = @languages - NLP::LANGUAGES
|
|
86
|
+
raise ArgumentError, "unknown language(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cache_key(text, _context)
|
|
90
|
+
"#{languages.join('+')}\n#{text}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def decide(text, _context)
|
|
94
|
+
# Clause by clause: a rule that reaches across a full stop is reading
|
|
95
|
+
# two statements as one, and a long page has a full stop every line.
|
|
96
|
+
# Anaphora is applied across that cut: "Ignore them" after a clause
|
|
97
|
+
# that named an instruction is the same pair as "Ignore the instructions".
|
|
98
|
+
clauses = NLP.clauses(text)
|
|
99
|
+
hits = NLP.clause_concepts(text, languages: languages).flat_map.with_index do |found, i|
|
|
100
|
+
clause_hits(clauses[i], found)
|
|
101
|
+
end
|
|
102
|
+
return pass if hits.empty?
|
|
103
|
+
|
|
104
|
+
block(categories: hits.map { |hit| hit[:label] }.uniq,
|
|
105
|
+
reason: "reworded instruction: #{hits.map { |hit| describe(hit) }.uniq.join('; ')}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def clause_hits(clause, found = nil)
|
|
111
|
+
found ||= NLP.concepts(clause, languages: languages)
|
|
112
|
+
return [] if found.empty?
|
|
113
|
+
|
|
114
|
+
present = found.to_set { |(_, concept, _)| concept }
|
|
115
|
+
length = NLP.words(clause).size
|
|
116
|
+
templates.filter_map do |template|
|
|
117
|
+
next unless Array(template[:requires]).all? { |concept| present.include?(concept) }
|
|
118
|
+
|
|
119
|
+
match(found, template, length)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Pairs within a window, found by walking the window rather than by
|
|
124
|
+
# comparing everything with everything.
|
|
125
|
+
#
|
|
126
|
+
# The obvious loop is lefts against rights, and it is quadratic in the
|
|
127
|
+
# number of concepts a document carries. That is invisible on ordinary
|
|
128
|
+
# prose, where a clause holds two or three, and it is reachable on
|
|
129
|
+
# purpose: a retrieved page is written by whoever wants it retrieved, and
|
|
130
|
+
# a page of "ignore ignore ignore ... instructions instructions" carries
|
|
131
|
+
# thousands of each with no pair close enough to match, so every one gets
|
|
132
|
+
# compared against every one. Measured before this rewrite: 51 ms at 5 KB,
|
|
133
|
+
# 1,298 ms at 39 KB, four times the work for twice the page, and the
|
|
134
|
+
# decoding pass runs it again per transform.
|
|
135
|
+
#
|
|
136
|
+
# A window is at most ten tokens, so walking it costs the same per concept
|
|
137
|
+
# whatever the page weighs.
|
|
138
|
+
def match(found, template, length)
|
|
139
|
+
first, second = template[:concepts]
|
|
140
|
+
seconds = Hash.new { |hash, key| hash[key] = [] }
|
|
141
|
+
found.each { |(index, concept, word)| seconds[index] << word if concept == second }
|
|
142
|
+
window = template[:window]
|
|
143
|
+
|
|
144
|
+
found.each do |(i, concept, left_word)|
|
|
145
|
+
next unless concept == first
|
|
146
|
+
|
|
147
|
+
((i - window)..(i + window)).each do |j|
|
|
148
|
+
# One word carrying both concepts is one fact, not two. "prompt" is
|
|
149
|
+
# an instruction and a secret at the same index, and a page that
|
|
150
|
+
# says it once has not said anything twice.
|
|
151
|
+
next if j == i
|
|
152
|
+
next if template[:ordered] && j < i && !verb_final_object?(i, length, left_word)
|
|
153
|
+
|
|
154
|
+
right_word = seconds[j].first
|
|
155
|
+
next unless right_word
|
|
156
|
+
|
|
157
|
+
return { label: template[:label], words: [left_word, right_word] }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Dutch subordinates put the verb last, so the object of an override
|
|
164
|
+
# sits to its left: "dat je de richtlijnen negeert". English "follow
|
|
165
|
+
# the guidance and ignore stale copies" has the override mid-clause
|
|
166
|
+
# with its own object after it, and stays unflagged.
|
|
167
|
+
#
|
|
168
|
+
# The verb has to be a Dutch one, because the rule is Dutch grammar and
|
|
169
|
+
# English ends clauses with these verbs constantly. Measured over 699
|
|
170
|
+
# installed manual pages, the position test alone took this rail from
|
|
171
|
+
# 1.6% of documents to 5.2%, and every added flag was a documentation URL
|
|
172
|
+
# whose last token happened to be "overrides":
|
|
173
|
+
#
|
|
174
|
+
# <https://doc.rust-lang.org/cargo/reference/config.html#command-line-overrides>
|
|
175
|
+
#
|
|
176
|
+
# The two override lexicons are disjoint, so asking which language the
|
|
177
|
+
# verb came from costs one lookup and gives the rule back its subject.
|
|
178
|
+
DUTCH_OVERRIDES = NLP::CONCEPTS[:nl][:override].map { |word| NLP.stem(word) }.to_set.freeze
|
|
179
|
+
|
|
180
|
+
def verb_final_object?(verb_index, length, word)
|
|
181
|
+
verb_index == length - 1 && DUTCH_OVERRIDES.include?(NLP.stem(NLP.normalize(word)))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def describe(hit)
|
|
185
|
+
"#{hit[:label]} (#{hit[:words].join(' ... ')})"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -21,16 +21,12 @@ module Vangrail
|
|
|
21
21
|
@reason = reason
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def offline?
|
|
25
|
-
true
|
|
26
|
-
end
|
|
27
|
-
|
|
28
24
|
def cache_key(text, _context)
|
|
29
25
|
text
|
|
30
26
|
end
|
|
31
27
|
|
|
32
|
-
def
|
|
33
|
-
hit = patterns.
|
|
28
|
+
def decide(text, _context)
|
|
29
|
+
hit = patterns.detect { |_label, pattern| pattern.match?(text.to_s) }
|
|
34
30
|
return pass unless hit
|
|
35
31
|
|
|
36
32
|
label = hit.first
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../completion'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# Reads how surprised a model is by the text, and blocks the span that is
|
|
9
|
+
# not language.
|
|
10
|
+
#
|
|
11
|
+
# The optimised attacks in the literature do not produce sentences. A
|
|
12
|
+
# gradient-search suffix reads like a hash with punctuation in it, and it
|
|
13
|
+
# works on the model while meaning nothing to a reader. No pattern catches
|
|
14
|
+
# it, because there is no pattern: the string is different every time it is
|
|
15
|
+
# searched for. What it has instead is a signature that is hard to remove
|
|
16
|
+
# while keeping the attack, which is that a language model finds it wildly
|
|
17
|
+
# improbable.
|
|
18
|
+
#
|
|
19
|
+
# So the check is the model's own log probabilities, which is the published
|
|
20
|
+
# detector for exactly this family. It needs an endpoint that will echo a
|
|
21
|
+
# prompt and score it, and many will not; when that is the case this rail
|
|
22
|
+
# reports uncertain and the deployment knows the family is uncovered rather
|
|
23
|
+
# than believing it is handled.
|
|
24
|
+
#
|
|
25
|
+
# Windowed rather than averaged over the whole text, because an attack is a
|
|
26
|
+
# short span inside an ordinary question. Twenty improbable tokens after a
|
|
27
|
+
# hundred readable ones barely move the mean, and the window is what keeps
|
|
28
|
+
# the signal from being diluted by the sentence the attacker wrapped it in.
|
|
29
|
+
#
|
|
30
|
+
# The threshold is not a constant of nature. Log probabilities depend on the
|
|
31
|
+
# model, its tokenizer, and its quantisation, so the default here is a
|
|
32
|
+
# starting point and script/perplexity_probe.rb is what turns it into a
|
|
33
|
+
# measured number for the endpoint in use. A deployment that has not run it
|
|
34
|
+
# is running an uncalibrated detector, and this rail is off by default for
|
|
35
|
+
# that reason as much as for the round trip.
|
|
36
|
+
class Perplexity < Rail
|
|
37
|
+
# Mean negative log likelihood per token, in nats. Ordinary prose under a
|
|
38
|
+
# small instruct model sits around 2 to 4; a gradient-search suffix sits
|
|
39
|
+
# far above it. Calibrate before trusting.
|
|
40
|
+
THRESHOLD = 7.0
|
|
41
|
+
|
|
42
|
+
# Tokens per window. Short enough that a twenty-token suffix fills one,
|
|
43
|
+
# long enough that a rare proper noun does not.
|
|
44
|
+
WINDOW = 16
|
|
45
|
+
|
|
46
|
+
# Below this many scored tokens there is no window worth taking, and a
|
|
47
|
+
# three-word question is not evidence of anything.
|
|
48
|
+
FLOOR = 8
|
|
49
|
+
|
|
50
|
+
attr_reader :completion, :threshold, :window
|
|
51
|
+
|
|
52
|
+
def initialize(completion:, threshold: THRESHOLD, window: WINDOW, floor: FLOOR,
|
|
53
|
+
name: 'perplexity', sides: %i[input context])
|
|
54
|
+
super(name: name, sides: sides)
|
|
55
|
+
@completion = completion
|
|
56
|
+
@threshold = threshold
|
|
57
|
+
@window = window
|
|
58
|
+
@floor = floor
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def offline?
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cache_key(text, _context)
|
|
66
|
+
"#{completion.model}\n#{threshold}\n#{window}\n#{text}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decide(text, _context)
|
|
70
|
+
body = text.to_s
|
|
71
|
+
return pass if body.strip.empty?
|
|
72
|
+
|
|
73
|
+
score = worst_window(body)
|
|
74
|
+
return pass if score.nil? || score < threshold
|
|
75
|
+
|
|
76
|
+
block(categories: ['high_perplexity'],
|
|
77
|
+
reason: format('a span of %<window>d tokens scores %<score>.1f nats against a threshold of ' \
|
|
78
|
+
'%<threshold>.1f: this is not language',
|
|
79
|
+
window: window, score: score, threshold: threshold))
|
|
80
|
+
rescue Error => e
|
|
81
|
+
unchecked("perplexity check did not run: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The highest mean negative log likelihood of any window, or nil when the
|
|
85
|
+
# text is too short to have one. Raises what the transport raises, so a
|
|
86
|
+
# probe script sees the error and a rail sees a Result.
|
|
87
|
+
def worst_window(text)
|
|
88
|
+
logprobs = completion.token_logprobs(text)
|
|
89
|
+
return nil if logprobs.size < @floor
|
|
90
|
+
|
|
91
|
+
size = [window, logprobs.size].min
|
|
92
|
+
means = (0..(logprobs.size - size)).map do |start|
|
|
93
|
+
slice = logprobs[start, size]
|
|
94
|
+
-slice.sum / slice.size
|
|
95
|
+
end
|
|
96
|
+
means.max
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -25,11 +25,18 @@ module Vangrail
|
|
|
25
25
|
# follows it. All three are in the corpus, because a rail that eats login
|
|
26
26
|
# examples is worse for a handbook than no rail at all.
|
|
27
27
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# one in eleven
|
|
31
|
-
#
|
|
32
|
-
#
|
|
28
|
+
# National identity numbers are matched only beside their own name, and the
|
|
29
|
+
# reason is the same false-positive budget. The Dutch BSN is nine digits
|
|
30
|
+
# with a checksum that one number in eleven passes by accident, so a rail
|
|
31
|
+
# reading bare nine-digit runs redacts job ids and project numbers out of
|
|
32
|
+
# cluster questions, which makes it unusable.
|
|
33
|
+
#
|
|
34
|
+
# A label changes that trade completely. "Mijn BSN is 123456782" carries
|
|
35
|
+
# the word and the checksum, and nothing on a cluster desk writes both by
|
|
36
|
+
# accident; a bare 123456782 keeps passing through untouched. It is the
|
|
37
|
+
# same pair-of-signals rule the rest of this gem uses, and it is what makes
|
|
38
|
+
# the most sensitive identifier a Dutch reader can paste into a support
|
|
39
|
+
# question something this rail can actually catch.
|
|
33
40
|
class PersonalData < Rail
|
|
34
41
|
PLACEHOLDER = '[redacted]'
|
|
35
42
|
|
|
@@ -55,7 +62,13 @@ module Vangrail
|
|
|
55
62
|
'phone' => /(?:\+|\b00)[1-9]\d{0,2}[\s.-]?(?:\(?\d{1,4}\)?[\s.-]?){2,5}\d{2,4}\b
|
|
56
63
|
|\b0\d{1,3}[\s.-]\d{3}[\s.-]?\d{3,4}\b/x,
|
|
57
64
|
'iban' => /\b[A-Z]{2}\d{2}\s?(?:[A-Z0-9]{4}\s?){2,7}[A-Z0-9]{1,4}\b/,
|
|
58
|
-
|
|
65
|
+
# Separators between the digits rather than after them: the trailing
|
|
66
|
+
# form eats the space before the next word and redacts it away.
|
|
67
|
+
'card' => /\b\d(?:[ -]?\d){12,18}\b/,
|
|
68
|
+
# The number beside its own name, in the words a Dutch reader uses for
|
|
69
|
+
# it. The digits may carry the dots or spaces a form prints them with.
|
|
70
|
+
'bsn' => /\b(?:bsn|burgerservicenummer|sofinummer|sofi[\s-]?nummer)\b
|
|
71
|
+
[^\n]{0,24}?((?:\d[\s.-]?){8}\d)\b/xi,
|
|
59
72
|
}.freeze
|
|
60
73
|
|
|
61
74
|
attr_reader :patterns, :placeholder
|
|
@@ -67,7 +80,7 @@ module Vangrail
|
|
|
67
80
|
@placeholder = placeholder
|
|
68
81
|
end
|
|
69
82
|
|
|
70
|
-
def
|
|
83
|
+
def language_agnostic?
|
|
71
84
|
true
|
|
72
85
|
end
|
|
73
86
|
|
|
@@ -75,7 +88,7 @@ module Vangrail
|
|
|
75
88
|
text
|
|
76
89
|
end
|
|
77
90
|
|
|
78
|
-
def
|
|
91
|
+
def decide(text, _context)
|
|
79
92
|
body = text.to_s
|
|
80
93
|
found = []
|
|
81
94
|
redacted = patterns.reduce(body) do |acc, (label, pattern)|
|
|
@@ -95,7 +108,10 @@ module Vangrail
|
|
|
95
108
|
next match unless redact?(label, match, m.pre_match, m.post_match)
|
|
96
109
|
|
|
97
110
|
found << label
|
|
98
|
-
|
|
111
|
+
# Where the pattern had to read a label to be sure, only the value
|
|
112
|
+
# goes. The reader still sees what the desk was told about, the same
|
|
113
|
+
# way the secrets rail keeps the setting name and loses the key.
|
|
114
|
+
m[1] ? match.sub(m[1], placeholder) : placeholder
|
|
99
115
|
end
|
|
100
116
|
end
|
|
101
117
|
|
|
@@ -105,6 +121,7 @@ module Vangrail
|
|
|
105
121
|
case label
|
|
106
122
|
when 'email' then mailbox?(match, before, after)
|
|
107
123
|
when 'card' then card?(match)
|
|
124
|
+
when 'bsn' then bsn?(match)
|
|
108
125
|
else true
|
|
109
126
|
end
|
|
110
127
|
end
|
|
@@ -127,6 +144,21 @@ module Vangrail
|
|
|
127
144
|
luhn?(digits)
|
|
128
145
|
end
|
|
129
146
|
|
|
147
|
+
# The elfproef, which is the check the Dutch government applies: the nine
|
|
148
|
+
# digits weighted 9 down to 2, with the last subtracted rather than
|
|
149
|
+
# added, and the total divisible by eleven. Nine identical digits pass it
|
|
150
|
+
# arithmetically and are a placeholder rather than a person, so they do
|
|
151
|
+
# not count.
|
|
152
|
+
def bsn?(match)
|
|
153
|
+
digits = match.gsub(/\D/, '')
|
|
154
|
+
return false unless digits.length == 9
|
|
155
|
+
return false if digits.chars.uniq.size == 1
|
|
156
|
+
|
|
157
|
+
weights = [9, 8, 7, 6, 5, 4, 3, 2, -1]
|
|
158
|
+
total = digits.chars.each_with_index.sum { |c, i| c.to_i * weights[i] }
|
|
159
|
+
(total % 11).zero?
|
|
160
|
+
end
|
|
161
|
+
|
|
130
162
|
def luhn?(digits)
|
|
131
163
|
sum = digits.reverse.chars.each_with_index.sum do |c, i|
|
|
132
164
|
n = c.to_i
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../nlp'
|
|
4
|
+
require_relative '../rail'
|
|
5
|
+
|
|
6
|
+
module Vangrail
|
|
7
|
+
module Rails
|
|
8
|
+
# Catches the answer that is reproducing the system prompt.
|
|
9
|
+
#
|
|
10
|
+
# Rails::Canary catches the exact token and nothing else, which its own
|
|
11
|
+
# documentation says: a model asked to summarise its instructions rather
|
|
12
|
+
# than repeat them leaks the content and not the marker. That is the
|
|
13
|
+
# published shape of the attack, and it is the row the coverage page marks
|
|
14
|
+
# as verbatim-only.
|
|
15
|
+
#
|
|
16
|
+
# The application already has the text that must not come back out. So
|
|
17
|
+
# nothing here has to guess what a system prompt looks like: the answer's
|
|
18
|
+
# sentences are compared against the protected text's sentences by n-gram
|
|
19
|
+
# containment, and a sentence made largely of the prompt's n-grams is the
|
|
20
|
+
# prompt, however it was introduced.
|
|
21
|
+
#
|
|
22
|
+
# Sentence against sentence, for the reason Rails::Similarity gives:
|
|
23
|
+
# containment saturates with length, so a short answer compared against a
|
|
24
|
+
# whole prompt scores high on nothing at all.
|
|
25
|
+
#
|
|
26
|
+
# Redacted rather than refused. An answer that quotes one line of its
|
|
27
|
+
# instructions is usually a useful answer with one bad sentence in it, and
|
|
28
|
+
# blocking throws away the help to prevent the leak while rewriting keeps
|
|
29
|
+
# both. What comes back is the answer with those sentences replaced.
|
|
30
|
+
class PromptLeak < Rail
|
|
31
|
+
PLACEHOLDER = '[redacted: system prompt]'
|
|
32
|
+
|
|
33
|
+
# Two thresholds, because reproducing a rule and applying one are not the
|
|
34
|
+
# same act and score alike.
|
|
35
|
+
#
|
|
36
|
+
# "I cannot speculate about quotas; ask the service desk" restates a line
|
|
37
|
+
# of the prompt and is exactly what the prompt is for. "My instructions
|
|
38
|
+
# say I cannot speculate about quotas" restates the same line and hands
|
|
39
|
+
# the reader the instruction. What separates them is not how much text
|
|
40
|
+
# they share; it is that the second one says whose words they are.
|
|
41
|
+
#
|
|
42
|
+
# So: a sentence that reproduces the protected text closely is a leak
|
|
43
|
+
# whatever frame it carries, and a sentence that reproduces it loosely is
|
|
44
|
+
# a leak only when it announces that it is quoting the assistant's own
|
|
45
|
+
# instructions. Measured in test/test_prompt_leak.rb: ordinary answers
|
|
46
|
+
# top out at 0.27, and quotes start at 0.45.
|
|
47
|
+
THRESHOLD = 0.7
|
|
48
|
+
FRAMED_THRESHOLD = 0.4
|
|
49
|
+
|
|
50
|
+
# A sentence naming the assistant's own instructions. Written here rather
|
|
51
|
+
# than in the shared lexicon on purpose: first-person possessives belong
|
|
52
|
+
# to an answer, and adding them to NLP's `self` concept would have the
|
|
53
|
+
# input side read "print my configuration" as an extraction attempt.
|
|
54
|
+
FRAME = /
|
|
55
|
+
\b(?:my|these|those|the|its|your)\s+
|
|
56
|
+
(?:instructions?|rules?|guidelines?|system\s+(?:prompt|message)|prompt|directives?)\b
|
|
57
|
+
|
|
|
58
|
+
\bI\s+(?:was|am|have\s+been)\s+(?:told|instructed|configured|programmed|asked)\b
|
|
59
|
+
|
|
|
60
|
+
\b(?:system|developer)\s+(?:prompt|message)\s+(?:says|states|reads|is)\b
|
|
61
|
+
|
|
|
62
|
+
\b(?:mijn|deze|die|jouw|uw)\s+
|
|
63
|
+
(?:instructies?|regels?|richtlijnen?|systeemprompt|voorschriften?)\b
|
|
64
|
+
|
|
|
65
|
+
\bik\s+(?:ben|werd|was)\s+(?:geïnstrueerd|geïnstrueerd|verteld|geconfigureerd|geprogrammeerd|gevraagd)\b
|
|
66
|
+
|
|
|
67
|
+
\bsysteemprompt\s+(?:zegt|staat|luidt|is)\b
|
|
68
|
+
/xi
|
|
69
|
+
|
|
70
|
+
# Shorter than this, a sentence is not evidence. "You may not." is inside
|
|
71
|
+
# the n-gram set of almost any prompt, and redacting it would cost a
|
|
72
|
+
# reader an answer to prevent nothing.
|
|
73
|
+
FLOOR = 40
|
|
74
|
+
|
|
75
|
+
attr_reader :threshold, :framed_threshold, :placeholder
|
|
76
|
+
|
|
77
|
+
def initialize(protected_text:, threshold: THRESHOLD, framed_threshold: FRAMED_THRESHOLD,
|
|
78
|
+
floor: FLOOR, placeholder: PLACEHOLDER, name: 'prompt_leak', sides: [:output])
|
|
79
|
+
super(name: name, sides: sides)
|
|
80
|
+
@threshold = threshold
|
|
81
|
+
@framed_threshold = framed_threshold
|
|
82
|
+
@floor = floor
|
|
83
|
+
@placeholder = placeholder
|
|
84
|
+
@protected = protect(protected_text)
|
|
85
|
+
raise ArgumentError, 'a prompt_leak rail needs protected text' if @protected.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def cache_key(text, _context)
|
|
89
|
+
"#{threshold}\n#{text}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def decide(text, _context)
|
|
93
|
+
body = text.to_s
|
|
94
|
+
leaked = sentences(body).select { |sentence| leak?(sentence) }
|
|
95
|
+
return pass if leaked.empty?
|
|
96
|
+
|
|
97
|
+
redacted = leaked.reduce(body) { |acc, sentence| acc.sub(sentence, placeholder) }
|
|
98
|
+
modify(redacted, categories: ['system_prompt'],
|
|
99
|
+
reason: "redacted #{leaked.size} sentence(s) reproducing the protected text")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# How much of the protected text a sentence reproduces, for a caller that
|
|
103
|
+
# wants the number rather than the verdict.
|
|
104
|
+
def score(sentence)
|
|
105
|
+
shingles = NLP.shingles(sentence)
|
|
106
|
+
@protected.map { |candidate| NLP.containment(shingles, candidate) }.max || 0.0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def sentences(text)
|
|
112
|
+
NLP.clauses(text).select { |clause| clause.length >= @floor }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def leak?(sentence)
|
|
116
|
+
found = score(sentence)
|
|
117
|
+
return true if found >= threshold
|
|
118
|
+
|
|
119
|
+
found >= framed_threshold && sentence.match?(FRAME)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The protected text as one shingle set per sentence. Sentences below the
|
|
123
|
+
# floor are dropped from it as well: a prompt's one-word line is not
|
|
124
|
+
# something an answer can leak.
|
|
125
|
+
def protect(text)
|
|
126
|
+
Array(text).flat_map { |part| NLP.clauses(part) }
|
|
127
|
+
.select { |clause| clause.length >= @floor }
|
|
128
|
+
.map { |clause| NLP.shingles(clause) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -20,13 +20,17 @@ module Vangrail
|
|
|
20
20
|
@client = client || Client.new(base_url: base_url, config_id: config_id, api_key: api_key)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def offline?
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
23
27
|
def cache_key(text, context)
|
|
24
28
|
return text if context[:side] == :input
|
|
25
29
|
|
|
26
30
|
"#{context[:user_input]} #{text}"
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
def
|
|
33
|
+
def decide(text, context)
|
|
30
34
|
result =
|
|
31
35
|
if context[:side] == :output
|
|
32
36
|
client.check_output(text, user_input: context[:user_input])
|
|
@@ -40,7 +40,7 @@ module Vangrail
|
|
|
40
40
|
@placeholder = placeholder
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
def
|
|
43
|
+
def language_agnostic?
|
|
44
44
|
true
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -48,7 +48,7 @@ module Vangrail
|
|
|
48
48
|
text
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def
|
|
51
|
+
def decide(text, _context)
|
|
52
52
|
body = text.to_s
|
|
53
53
|
found = []
|
|
54
54
|
redacted = patterns.reduce(body) do |acc, (label, pattern)|
|
|
@@ -35,23 +35,26 @@ module Vangrail
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def offline?
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
38
42
|
def cache_key(text, context)
|
|
39
43
|
return text if context[:side] == :input
|
|
40
44
|
|
|
41
45
|
"#{context[:user_input]} #{text}"
|
|
42
46
|
end
|
|
43
47
|
|
|
44
|
-
def
|
|
48
|
+
def decide(text, context)
|
|
45
49
|
rendered = Prompt.render(policy, template_context(text, context))
|
|
46
50
|
answer = chat.ask([
|
|
47
51
|
{ 'role' => 'system', 'content' => rendered },
|
|
48
|
-
{ 'role' => 'user', 'content' => text.to_s }
|
|
52
|
+
{ 'role' => 'user', 'content' => text.to_s },
|
|
49
53
|
])
|
|
50
54
|
parsed = Parsers.policy(answer.text)
|
|
51
55
|
unless parsed[:decided]
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
reason: "unparsed judge response: #{parsed[:reason]}")
|
|
56
|
+
return unchecked("unparsed judge response: #{parsed[:reason]}",
|
|
57
|
+
model: model, latency_ms: answer.latency_ms, raw: answer.raw)
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
return pass(model: model, latency_ms: answer.latency_ms, raw: answer.raw) unless parsed[:violated]
|
|
@@ -69,7 +72,7 @@ module Vangrail
|
|
|
69
72
|
{
|
|
70
73
|
'user_input' => (context[:side] == :input ? text : context[:user_input]).to_s,
|
|
71
74
|
'bot_response' => (context[:side] == :output ? text : '').to_s,
|
|
72
|
-
'context' => context
|
|
75
|
+
'context' => context,
|
|
73
76
|
}
|
|
74
77
|
end
|
|
75
78
|
|