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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +655 -43
  3. data/lib/vangrail/actions.rb +10 -3
  4. data/lib/vangrail/assessor.rb +249 -0
  5. data/lib/vangrail/bayes_data.rb +340 -0
  6. data/lib/vangrail/beta.rb +102 -0
  7. data/lib/vangrail/builder.rb +354 -0
  8. data/lib/vangrail/chat.rb +17 -15
  9. data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
  10. data/lib/vangrail/client.rb +27 -18
  11. data/lib/vangrail/colang/ast.rb +29 -3
  12. data/lib/vangrail/colang/interpreter.rb +55 -31
  13. data/lib/vangrail/colang/parser.rb +19 -61
  14. data/lib/vangrail/colang/value_parser.rb +161 -0
  15. data/lib/vangrail/completion.rb +86 -0
  16. data/lib/vangrail/config.rb +35 -15
  17. data/lib/vangrail/conversation.rb +240 -11
  18. data/lib/vangrail/dojo.rb +126 -0
  19. data/lib/vangrail/embeddings.rb +87 -0
  20. data/lib/vangrail/engine.rb +29 -70
  21. data/lib/vangrail/errors.rb +6 -1
  22. data/lib/vangrail/evidence.rb +303 -0
  23. data/lib/vangrail/evidence_data.rb +113 -0
  24. data/lib/vangrail/http.rb +18 -13
  25. data/lib/vangrail/judgement.rb +151 -0
  26. data/lib/vangrail/known_attacks.rb +45 -0
  27. data/lib/vangrail/linear_model.rb +124 -0
  28. data/lib/vangrail/nlp.rb +596 -0
  29. data/lib/vangrail/origin.rb +249 -0
  30. data/lib/vangrail/parsers.rb +5 -5
  31. data/lib/vangrail/profile.rb +114 -0
  32. data/lib/vangrail/prompt.rb +14 -3
  33. data/lib/vangrail/provider.rb +106 -75
  34. data/lib/vangrail/providers/gateway.rb +15 -14
  35. data/lib/vangrail/providers/llmlite.rb +25 -10
  36. data/lib/vangrail/providers.rb +6 -8
  37. data/lib/vangrail/rail.rb +46 -8
  38. data/lib/vangrail/rails/alignment.rb +91 -0
  39. data/lib/vangrail/rails/bayes.rb +115 -0
  40. data/lib/vangrail/rails/budget.rb +2 -2
  41. data/lib/vangrail/rails/canary.rb +2 -2
  42. data/lib/vangrail/rails/colang_flow.rb +9 -1
  43. data/lib/vangrail/rails/escalation.rb +15 -8
  44. data/lib/vangrail/rails/exfiltration.rb +2 -2
  45. data/lib/vangrail/rails/grounding.rb +8 -5
  46. data/lib/vangrail/rails/guard_model.rb +7 -4
  47. data/lib/vangrail/rails/hidden.rb +52 -9
  48. data/lib/vangrail/rails/injected_instructions.rb +29 -9
  49. data/lib/vangrail/rails/jailbreak.rb +2 -6
  50. data/lib/vangrail/rails/known_answer.rb +6 -2
  51. data/lib/vangrail/rails/language.rb +87 -0
  52. data/lib/vangrail/rails/linear.rb +80 -0
  53. data/lib/vangrail/rails/many_shot.rb +2 -6
  54. data/lib/vangrail/rails/markup.rb +3 -3
  55. data/lib/vangrail/rails/missing.rb +1 -5
  56. data/lib/vangrail/rails/obfuscation.rb +81 -13
  57. data/lib/vangrail/rails/paraphrase.rb +189 -0
  58. data/lib/vangrail/rails/pattern.rb +2 -6
  59. data/lib/vangrail/rails/perplexity.rb +100 -0
  60. data/lib/vangrail/rails/personal_data.rb +41 -9
  61. data/lib/vangrail/rails/prompt_leak.rb +132 -0
  62. data/lib/vangrail/rails/remote.rb +5 -1
  63. data/lib/vangrail/rails/secrets.rb +2 -2
  64. data/lib/vangrail/rails/self_check.rb +9 -6
  65. data/lib/vangrail/rails/semantic.rb +132 -0
  66. data/lib/vangrail/rails/similarity.rb +96 -0
  67. data/lib/vangrail/rails/trajectory.rb +10 -5
  68. data/lib/vangrail/result.rb +3 -3
  69. data/lib/vangrail/result_cache.rb +0 -0
  70. data/lib/vangrail/screening.rb +68 -0
  71. data/lib/vangrail/session.rb +365 -0
  72. data/lib/vangrail/spotlight.rb +48 -8
  73. data/lib/vangrail/stream_guard.rb +8 -6
  74. data/lib/vangrail/tools.rb +58 -0
  75. data/lib/vangrail/version.rb +1 -1
  76. data/lib/vangrail.rb +39 -258
  77. 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 call(text, _context)
33
- hit = patterns.find { |_label, pattern| pattern.match?(text.to_s) }
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
- # Deliberately not included: national identity numbers. The Dutch BSN is
29
- # nine digits with a checksum, a Slurm job id is six to eight digits, and
30
- # one in eleven job ids passes the checksum by accident. A rail that
31
- # redacts job ids from a cluster support question is unusable, and the
32
- # trade is not close.
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
- 'card' => /\b(?:\d[ -]?){13,19}\b/
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 offline?
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 call(text, _context)
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
- placeholder
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 call(text, context)
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 offline?
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 call(text, _context)
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 call(text, context)
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 Result.new(status: :passed, rail: name, certain: false, model: model,
53
- latency_ms: answer.latency_ms, raw: answer.raw,
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