vangrail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +473 -0
  4. data/lib/vangrail/actions.rb +61 -0
  5. data/lib/vangrail/chat.rb +88 -0
  6. data/lib/vangrail/client/completion.rb +122 -0
  7. data/lib/vangrail/client.rb +219 -0
  8. data/lib/vangrail/colang/ast.rb +53 -0
  9. data/lib/vangrail/colang/interpreter.rb +131 -0
  10. data/lib/vangrail/colang/library.rb +53 -0
  11. data/lib/vangrail/colang/parser.rb +222 -0
  12. data/lib/vangrail/config.rb +270 -0
  13. data/lib/vangrail/confusables.rb +67 -0
  14. data/lib/vangrail/confusables_data.rb +1673 -0
  15. data/lib/vangrail/conversation.rb +105 -0
  16. data/lib/vangrail/engine.rb +240 -0
  17. data/lib/vangrail/errors.rb +48 -0
  18. data/lib/vangrail/http.rb +109 -0
  19. data/lib/vangrail/parsers.rb +181 -0
  20. data/lib/vangrail/policies.rb +202 -0
  21. data/lib/vangrail/prompt.rb +88 -0
  22. data/lib/vangrail/provider.rb +191 -0
  23. data/lib/vangrail/providers/gateway.rb +131 -0
  24. data/lib/vangrail/providers/llmlite.rb +71 -0
  25. data/lib/vangrail/providers.rb +72 -0
  26. data/lib/vangrail/rail.rb +93 -0
  27. data/lib/vangrail/rails/budget.rb +63 -0
  28. data/lib/vangrail/rails/canary.rb +76 -0
  29. data/lib/vangrail/rails/colang_flow.rb +40 -0
  30. data/lib/vangrail/rails/escalation.rb +178 -0
  31. data/lib/vangrail/rails/exfiltration.rb +167 -0
  32. data/lib/vangrail/rails/grounding.rb +64 -0
  33. data/lib/vangrail/rails/guard_model.rb +96 -0
  34. data/lib/vangrail/rails/hidden.rb +105 -0
  35. data/lib/vangrail/rails/injected_instructions.rb +86 -0
  36. data/lib/vangrail/rails/jailbreak.rb +114 -0
  37. data/lib/vangrail/rails/known_answer.rb +118 -0
  38. data/lib/vangrail/rails/many_shot.rb +80 -0
  39. data/lib/vangrail/rails/markup.rb +77 -0
  40. data/lib/vangrail/rails/missing.rb +38 -0
  41. data/lib/vangrail/rails/obfuscation.rb +186 -0
  42. data/lib/vangrail/rails/pattern.rb +57 -0
  43. data/lib/vangrail/rails/personal_data.rb +152 -0
  44. data/lib/vangrail/rails/remote.rb +40 -0
  45. data/lib/vangrail/rails/secrets.rb +77 -0
  46. data/lib/vangrail/rails/self_check.rb +81 -0
  47. data/lib/vangrail/rails/trajectory.rb +101 -0
  48. data/lib/vangrail/result.rb +114 -0
  49. data/lib/vangrail/result_cache.rb +0 -0
  50. data/lib/vangrail/spotlight.rb +157 -0
  51. data/lib/vangrail/stream_guard.rb +163 -0
  52. data/lib/vangrail/version.rb +5 -0
  53. data/lib/vangrail.rb +354 -0
  54. metadata +120 -0
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Removes markup that does something when the answer is rendered.
8
+ #
9
+ # An answer is text until a client renders it, and most clients render
10
+ # markdown by pulling in a library that passes raw HTML straight through.
11
+ # At that point a script tag in the answer is a script tag in the page, and
12
+ # the model wrote it because a retrieved document told it to. That is a
13
+ # cross-site scripting bug with a language model in the middle of it, and
14
+ # the fact that the model is the delivery mechanism does not make it a
15
+ # different class of bug.
16
+ #
17
+ # The application's own sanitiser is the real defence and this does not
18
+ # replace it. What this covers is the case where there is no sanitiser,
19
+ # which is most of them, and where the answer is passed to a renderer that
20
+ # was chosen for how its tables look.
21
+ #
22
+ # Removes rather than blocks. An answer with a script tag in it is an answer
23
+ # with one bad span, the same as an answer with a credential in it.
24
+ #
25
+ # Not on by default. A desk whose client renders markdown as text, or which
26
+ # escapes before rendering, does not need it, and a rail that strips markup
27
+ # nobody was going to execute is noise in the result.
28
+ class Markup < Rail
29
+ PATTERNS = {
30
+ # Executes on load.
31
+ 'script' => /<script\b[^>]*>.*?<\/script>|<script\b[^>]*\/?>/mi,
32
+ # Loads and executes something else.
33
+ 'frame' => /<(?:iframe|frame|embed|object|applet)\b[^>]*>(?:.*?<\/(?:iframe|frame|embed|object|applet)>)?/mi,
34
+ # Runs on an event, which is how a lone img tag becomes an exploit.
35
+ 'event_handler' => /\son[a-z]{3,20}\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i,
36
+ # A scheme that executes rather than fetches.
37
+ 'active_scheme' => /(?:javascript|vbscript|data)\s*:\s*[^\s"'<>)]+/i,
38
+ # Rewrites where a form or a link goes, or what the page loads next.
39
+ 'meta_refresh' => /<meta\b[^>]*http-equiv\s*=\s*["']?refresh["']?[^>]*>/i,
40
+ 'base_tag' => /<base\b[^>]*>/i,
41
+ 'form' => /<form\b[^>]*>.*?<\/form>|<form\b[^>]*>/mi,
42
+ # Styling can position an invisible overlay over the page.
43
+ 'style_block' => /<style\b[^>]*>.*?<\/style>/mi
44
+ }.freeze
45
+
46
+ attr_reader :patterns
47
+
48
+ def initialize(patterns: PATTERNS, name: 'markup', sides: [:output])
49
+ super(name: name, sides: sides)
50
+ @patterns = patterns
51
+ end
52
+
53
+ def offline?
54
+ true
55
+ end
56
+
57
+ def cache_key(text, _context)
58
+ text
59
+ end
60
+
61
+ def call(text, _context)
62
+ body = text.to_s
63
+ found = []
64
+ cleaned = patterns.reduce(body) do |acc, (label, pattern)|
65
+ acc.gsub(pattern) do
66
+ found << label
67
+ ''
68
+ end
69
+ end
70
+ return pass if found.empty?
71
+
72
+ modify(cleaned, categories: found.uniq,
73
+ reason: "removed #{found.uniq.join(', ')} from the answer")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Stands in for a rail that was asked for and could not be built.
8
+ #
9
+ # Without this, a configuration asking for a model-backed rail against an
10
+ # endpoint that is down produces an engine holding only its offline rails.
11
+ # Those rails pass, the engine reports passed and certain, and the answer is
12
+ # a lie: the check the operator configured never ran.
13
+ #
14
+ # A placeholder that always returns unchecked keeps the arithmetic right.
15
+ # The turn is allowed, the engine's result is uncertain, and the reason
16
+ # names what is missing and why.
17
+ class Missing < Rail
18
+ attr_reader :reason
19
+
20
+ def initialize(reason:, name: 'missing', sides: Rail::SIDES)
21
+ super(name: name, sides: sides)
22
+ @reason = reason
23
+ end
24
+
25
+ def offline?
26
+ true
27
+ end
28
+
29
+ def placeholder?
30
+ true
31
+ end
32
+
33
+ def call(_text, _context)
34
+ unchecked(reason)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../confusables'
4
+ require_relative '../rail'
5
+
6
+ module Vangrail
7
+ module Rails
8
+ # Runs other rails again over the text an attacker actually meant.
9
+ #
10
+ # Every pattern rail reads what is written. An attacker who knows that
11
+ # writes it differently: base64 the paragraph and ask the model to decode
12
+ # it, rot13 it, spell it with Cyrillic letters that look like Latin ones,
13
+ # put zero-width joiners between the letters of "ignore", or set a
14
+ # right-to-left override so the rendered page and the byte sequence say
15
+ # different things. The model reads through all of it, because that is what
16
+ # models do, and the regexps see nothing.
17
+ #
18
+ # The answer is not more patterns. It is to undo the encoding and run the
19
+ # rails that already exist over the result, which is why this takes a rail
20
+ # list rather than defining checks of its own:
21
+ #
22
+ # Rails::Obfuscation.new(rails: [Rails::InjectedInstructions.new,
23
+ # Rails::Jailbreak.new])
24
+ #
25
+ # Each transform is applied on its own, and a variant identical to the
26
+ # original is dropped, so ordinary text costs one comparison per transform
27
+ # and nothing else. A hit names both the rail and the encoding it was
28
+ # hiding under, because "blocked" without that is unactionable for whoever
29
+ # has to look at the page.
30
+ #
31
+ # Invisible characters are handled here directly rather than by a delegate:
32
+ # they are stripped, and the strip is reported as a rewrite. A zero-width
33
+ # joiner inside a word has no honest use in a handbook, and removing it
34
+ # costs a reader nothing while denying the cheapest bypass there is.
35
+ #
36
+ # What this does not do is guess. There is no scoring, no entropy
37
+ # threshold, no "this looks encoded" heuristic that would fire on the base64
38
+ # blobs and hashes a cluster handbook is full of. A blob either decodes to
39
+ # text a rail objects to, or it does not.
40
+ class Obfuscation < Rail
41
+ # Zero-width and bidi control characters. The first four are the invisible
42
+ # separators; the bidi set is the trojan-source family, where the rendered
43
+ # order and the stored order disagree.
44
+ INVISIBLE = /[​-‍⁠᠎‪-‮⁦-⁩]/
45
+
46
+ # A base64 run long enough to hold a sentence. Below this the decode is
47
+ # noise, and a handbook is full of short tokens that happen to be in the
48
+ # alphabet.
49
+ #
50
+ # Bounded by lookaround rather than \b, because + and / are not word
51
+ # characters: a blob ending in one had its last character trimmed off the
52
+ # match, and a base64 string one character short decodes to a sentence
53
+ # with its tail missing. That cost the corpus a case, and the case it
54
+ # cost was an HTML comment, whose pattern needs the closing marker.
55
+ BASE64 = /(?<![A-Za-z0-9+\/=])[A-Za-z0-9+\/]{24,}={0,2}(?![A-Za-z0-9+\/=])/
56
+
57
+ attr_reader :rails, :transforms
58
+
59
+ def initialize(rails:, transforms: %i[invisible confusables confusables_all rot13 base64 nfkc],
60
+ name: 'obfuscation', sides: %i[input context])
61
+ super(name: name, sides: sides)
62
+ @rails = Array(rails)
63
+ @transforms = Array(transforms).map(&:to_sym)
64
+ end
65
+
66
+ # Only if everything it delegates to is. A wrapper around a model rail
67
+ # inherits the model rail's posture.
68
+ def offline?
69
+ rails.all?(&:offline?)
70
+ end
71
+
72
+ def cache_key(text, _context)
73
+ text if offline?
74
+ end
75
+
76
+ def call(text, context)
77
+ body = text.to_s
78
+ stripped = body.gsub(INVISIBLE, '')
79
+
80
+ hit = first_objection(body, stripped, context)
81
+ return hit if hit
82
+
83
+ return pass if stripped == body
84
+
85
+ modify(stripped, categories: ['invisible_characters'],
86
+ reason: 'removed zero-width or bidi control characters')
87
+ end
88
+
89
+ # The decoded forms of a text, labelled. Public because an application
90
+ # that logs a blocked page wants to show what it decoded to.
91
+ def variants(text)
92
+ body = text.to_s
93
+ transforms.filter_map do |name|
94
+ decoded = apply(name, body)
95
+ next if decoded.nil? || decoded == body || decoded.strip.empty?
96
+
97
+ [name, decoded]
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # Stops at the first rail that objects to any variant. Order is the
104
+ # caller's rail order, then transform order, so the reported reason is
105
+ # stable rather than whichever regexp happened to be quickest.
106
+ def first_objection(body, stripped, context)
107
+ candidates = []
108
+ # The stripped text is a variant in its own right when anything was
109
+ # removed: "i<zwj>gnore previous instructions" is the whole attack, and
110
+ # every other transform runs after the removal rather than instead of it.
111
+ candidates << [:invisible, stripped] unless stripped == body
112
+ candidates.concat(variants(stripped))
113
+
114
+ candidates.each do |name, decoded|
115
+ rails.each do |rail|
116
+ result = rail.call(decoded, context)
117
+ next unless result.blocked?
118
+
119
+ return block(categories: (result.categories || []) + ["encoded:#{name}"],
120
+ reason: "#{result.reason} (hidden with #{name})")
121
+ end
122
+ end
123
+ nil
124
+ end
125
+
126
+ def apply(name, body)
127
+ case name
128
+ when :invisible then body.gsub(INVISIBLE, '')
129
+ when :confusables then defold(body)
130
+ when :confusables_all then Confusables.fold_all(body)
131
+ when :rot13 then body.tr('A-Za-z', 'N-ZA-Mn-za-m')
132
+ when :base64 then decode_base64(body)
133
+ when :nfkc then normalise(body)
134
+ end
135
+ end
136
+
137
+ # Two readings of the same text, because the policies fail in opposite
138
+ # directions and a variant costs one comparison.
139
+ #
140
+ # :confusables folds only words that mix ASCII with imitators, which is
141
+ # what an imitation attack looks like and what leaves a page of Russian
142
+ # alone. It misses a word converted character for character, where
143
+ # nothing ASCII is left to mix with.
144
+ #
145
+ # :confusables_all folds everything and catches that, at the price of
146
+ # turning genuine Cyrillic into noise. The noise is never shown to
147
+ # anybody: a variant exists to be read by a pattern, and the corpus
148
+ # asserts that ordinary non-Latin prose does not match one.
149
+ def defold(body)
150
+ Confusables.fold(body)
151
+ end
152
+
153
+ # Every long base64 run in the text, decoded and joined. A run that is not
154
+ # valid base64, or that decodes to bytes rather than text, contributes
155
+ # nothing rather than failing the whole pass: a handbook page carrying one
156
+ # hash and one payload should still have the payload read.
157
+ def decode_base64(body)
158
+ pieces = body.scan(BASE64).filter_map { |run| readable(run) }
159
+ pieces.empty? ? nil : pieces.join("\n")
160
+ end
161
+
162
+ def readable(run)
163
+ decoded = run.unpack1('m')
164
+ return nil if decoded.nil? || decoded.empty?
165
+
166
+ decoded.force_encoding(Encoding::UTF_8)
167
+ return nil unless decoded.valid_encoding?
168
+ # Printable enough to be a sentence rather than a compressed blob.
169
+ return nil if decoded.count("^ -~\n\t").positive?
170
+
171
+ decoded
172
+ rescue ArgumentError
173
+ nil
174
+ end
175
+
176
+ # Compatibility normalisation folds the fullwidth and mathematical
177
+ # alphabets, which is the other cheap way to write a word a regexp will
178
+ # not recognise.
179
+ def normalise(body)
180
+ body.unicode_normalize(:nfkc)
181
+ rescue ArgumentError, Encoding::CompatibilityError
182
+ nil
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Blocks text matching any of a list of patterns.
8
+ #
9
+ # A guardrail stack that is only a language model has no floor: when the
10
+ # endpoint is slow, cold, or down, every check degrades at once. A pattern
11
+ # rail decides in microseconds, offline, and identically every time, so the
12
+ # cases you can state exactly stay covered no matter what the network does.
13
+ #
14
+ # Pattern.new(patterns: { 'instruction_override' => /ignore (all )?previous instructions/i })
15
+ class Pattern < Rail
16
+ attr_reader :patterns
17
+
18
+ def initialize(patterns:, name: 'pattern', sides: Rail::SIDES, reason: nil)
19
+ super(name: name, sides: sides)
20
+ @patterns = normalize(patterns)
21
+ @reason = reason
22
+ end
23
+
24
+ def offline?
25
+ true
26
+ end
27
+
28
+ def cache_key(text, _context)
29
+ text
30
+ end
31
+
32
+ def call(text, _context)
33
+ hit = patterns.find { |_label, pattern| pattern.match?(text.to_s) }
34
+ return pass unless hit
35
+
36
+ label = hit.first
37
+ block(categories: [label], reason: @reason || "matched #{label}")
38
+ end
39
+
40
+ private
41
+
42
+ def normalize(patterns)
43
+ case patterns
44
+ when Hash then patterns.to_h { |k, v| [k.to_s, to_regexp(v)] }
45
+ when Array then patterns.each_with_index.to_h { |v, i| ["pattern_#{i + 1}", to_regexp(v)] }
46
+ else { 'pattern_1' => to_regexp(patterns) }
47
+ end
48
+ end
49
+
50
+ def to_regexp(value)
51
+ return value if value.is_a?(Regexp)
52
+
53
+ Regexp.new(Regexp.escape(value.to_s), Regexp::IGNORECASE)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Redacts a reader's own details before the question leaves the building.
8
+ #
9
+ # This is a privacy rail rather than a security one, and it exists because
10
+ # of where the text goes next. A question typed into a documentation desk
11
+ # is about to be sent to a model endpoint, which may be a third party, may
12
+ # log, and may sit in another jurisdiction. A reader pasting a support
13
+ # thread into it has not thought about any of that, and nothing in the
14
+ # answer needs their phone number.
15
+ #
16
+ # Redacts rather than blocks, for the same reason the secrets rail does: the
17
+ # question is answerable, and one span in it should not have been sent.
18
+ #
19
+ # The hard part on a cluster desk is not detection. It is that
20
+ # `ssh rgoswami@snellius.example.org` is an email address by every
21
+ # syntactic measure, and redacting it destroys the answer to the most
22
+ # commonly asked question there is. So an address is left alone when it is
23
+ # inside backticks or a fence, when its line carries a command that takes a
24
+ # user@host argument or an ssh config keyword, or when a remote path
25
+ # follows it. All three are in the corpus, because a rail that eats login
26
+ # examples is worse for a handbook than no rail at all.
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.
33
+ class PersonalData < Rail
34
+ PLACEHOLDER = '[redacted]'
35
+
36
+ # Commands whose argument is a login target rather than a mailbox. Read
37
+ # over the line rather than the character before the match: scp puts a
38
+ # source path in between, and an ssh config line has no command on it at
39
+ # all, only the User keyword.
40
+ HOST_COMMANDS = /\b(?:ssh|scp|sftp|rsync|mosh|ssh-copy-id|ssh:\/\/|sftp:\/\/|User)\b/i
41
+
42
+ # The other half of scp and rsync syntax: an address followed by a remote
43
+ # path is a target, not a mailbox.
44
+ REMOTE_PATH = /\A:[~\/\w.]/
45
+
46
+ # Local parts that are documentation rather than a person.
47
+ PLACEHOLDER_USERS = /\A(?:user|username|your[._-]?name|login|account|me|example|
48
+ firstname|lastname|name|admin|root)\z/xi
49
+
50
+ PATTERNS = {
51
+ 'email' => /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
52
+ # International or national, with a separator, long enough to be a
53
+ # phone number and not a job id: a leading + or 00, or a leading zero
54
+ # with grouping.
55
+ 'phone' => /(?:\+|\b00)[1-9]\d{0,2}[\s.-]?(?:\(?\d{1,4}\)?[\s.-]?){2,5}\d{2,4}\b
56
+ |\b0\d{1,3}[\s.-]\d{3}[\s.-]?\d{3,4}\b/x,
57
+ '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/
59
+ }.freeze
60
+
61
+ attr_reader :patterns, :placeholder
62
+
63
+ def initialize(patterns: PATTERNS, placeholder: PLACEHOLDER,
64
+ name: 'personal_data', sides: [:input])
65
+ super(name: name, sides: sides)
66
+ @patterns = patterns
67
+ @placeholder = placeholder
68
+ end
69
+
70
+ def offline?
71
+ true
72
+ end
73
+
74
+ def cache_key(text, _context)
75
+ text
76
+ end
77
+
78
+ def call(text, _context)
79
+ body = text.to_s
80
+ found = []
81
+ redacted = patterns.reduce(body) do |acc, (label, pattern)|
82
+ replace(acc, label, pattern, found)
83
+ end
84
+ return pass if found.empty?
85
+
86
+ modify(redacted, categories: found.uniq,
87
+ reason: "redacted #{found.uniq.join(', ')} before sending")
88
+ end
89
+
90
+ private
91
+
92
+ def replace(body, label, pattern, found)
93
+ body.gsub(pattern) do |match|
94
+ m = Regexp.last_match
95
+ next match unless redact?(label, match, m.pre_match, m.post_match)
96
+
97
+ found << label
98
+ placeholder
99
+ end
100
+ end
101
+
102
+ def redact?(label, match, before, after)
103
+ return false if in_code?(before)
104
+
105
+ case label
106
+ when 'email' then mailbox?(match, before, after)
107
+ when 'card' then card?(match)
108
+ else true
109
+ end
110
+ end
111
+
112
+ # An address is a mailbox unless it is a login target or a placeholder in
113
+ # documentation.
114
+ def mailbox?(match, before, after)
115
+ return false if after.match?(REMOTE_PATH)
116
+ return false if before[/[^\n]*\z/].match?(HOST_COMMANDS)
117
+
118
+ !match.split('@').first.match?(PLACEHOLDER_USERS)
119
+ end
120
+
121
+ # Digits alone are not a card number. A cluster question is full of long
122
+ # numbers, so the check has to be the one the issuers use.
123
+ def card?(match)
124
+ digits = match.gsub(/\D/, '')
125
+ return false unless digits.length.between?(13, 19)
126
+
127
+ luhn?(digits)
128
+ end
129
+
130
+ def luhn?(digits)
131
+ sum = digits.reverse.chars.each_with_index.sum do |c, i|
132
+ n = c.to_i
133
+ next n if i.even?
134
+
135
+ n * 2 > 9 ? (n * 2) - 9 : n * 2
136
+ end
137
+ (sum % 10).zero?
138
+ end
139
+
140
+ # Inside backticks or a fence, the text is a command rather than a
141
+ # person's details, and rewriting a command is how a guardrail becomes
142
+ # the thing the reader has to work around.
143
+ def in_code?(before)
144
+ before.count('`').odd? || fenced?(before)
145
+ end
146
+
147
+ def fenced?(before)
148
+ before.scan(/^```/).size.odd?
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../client'
4
+ require_relative '../rail'
5
+
6
+ module Vangrail
7
+ module Rails
8
+ # A NeMo Guardrails server as one rail among the local ones.
9
+ #
10
+ # Wrapping the client in the rail protocol is what keeps the two paths from
11
+ # becoming two designs. A team migrating off the Python service can put this
12
+ # rail and a local one in the same ordered list, compare them on live
13
+ # traffic, and drop the remote one when the local rails cover it.
14
+ class Remote < Rail
15
+ attr_reader :client
16
+
17
+ def initialize(client: nil, base_url: nil, config_id: nil, api_key: nil,
18
+ name: 'remote', sides: Rail::SIDES)
19
+ super(name: name, sides: sides)
20
+ @client = client || Client.new(base_url: base_url, config_id: config_id, api_key: api_key)
21
+ end
22
+
23
+ def cache_key(text, context)
24
+ return text if context[:side] == :input
25
+
26
+ "#{context[:user_input]} #{text}"
27
+ end
28
+
29
+ def call(text, context)
30
+ result =
31
+ if context[:side] == :output
32
+ client.check_output(text, user_input: context[:user_input])
33
+ else
34
+ client.check_input(text)
35
+ end
36
+ result.with_rail(name)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Redacts credentials, returning :modified rather than blocking.
8
+ #
9
+ # This is the rail that justifies having three statuses. An answer that
10
+ # quotes a config file with a live token is a useful answer with one bad
11
+ # span in it: blocking it throws away the help, and passing it leaks the
12
+ # token. Replacing the span keeps both halves honest, and the caller can see
13
+ # from the status that what it is about to show has been edited.
14
+ #
15
+ # Patterns cover shapes that are unambiguous on sight. Anything needing
16
+ # judgement belongs in a policy rail, not here: a false positive silently
17
+ # corrupts an answer, which is worse than a missed match a later rail can
18
+ # still catch.
19
+ class Secrets < Rail
20
+ PLACEHOLDER = '[redacted]'
21
+
22
+ DEFAULT_PATTERNS = {
23
+ 'private_key' => /-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----/m,
24
+ 'openai_key' => /\bsk-[A-Za-z0-9_-]{20,}\b/,
25
+ 'anthropic_key' => /\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
26
+ 'github_token' => /\bgh[pousr]_[A-Za-z0-9]{30,}\b/,
27
+ 'slack_token' => /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/,
28
+ 'aws_access_key' => /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
29
+ 'jwt' => /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
30
+ 'bearer_header' => /\b(?i:authorization)\s*:\s*(?i:bearer)\s+\S{12,}/,
31
+ 'inline_password' => /\b(?i:password|passwd|api[_-]?key|secret)\s*[=:]\s*(?!\[redacted\])\S{6,}/
32
+ }.freeze
33
+
34
+ attr_reader :patterns, :placeholder
35
+
36
+ def initialize(patterns: DEFAULT_PATTERNS, placeholder: PLACEHOLDER, name: 'secrets',
37
+ sides: [:output])
38
+ super(name: name, sides: sides)
39
+ @patterns = patterns
40
+ @placeholder = placeholder
41
+ end
42
+
43
+ def offline?
44
+ true
45
+ end
46
+
47
+ def cache_key(text, _context)
48
+ text
49
+ end
50
+
51
+ def call(text, _context)
52
+ body = text.to_s
53
+ found = []
54
+ redacted = patterns.reduce(body) do |acc, (label, pattern)|
55
+ acc.gsub(pattern) do |match|
56
+ found << label
57
+ replacement(label, match)
58
+ end
59
+ end
60
+ return pass if found.empty?
61
+
62
+ modify(redacted, categories: found.uniq, reason: "redacted #{found.uniq.join(', ')}")
63
+ end
64
+
65
+ private
66
+
67
+ # Keeps the key name visible so the reader still learns which setting the
68
+ # answer was talking about, and loses only the value.
69
+ def replacement(label, match)
70
+ return "#{match[/\A[^=:]*[=:]\s*/]}#{placeholder}" if label == 'inline_password'
71
+ return "#{match[/\A\S+\s*:\s*\S+\s/]}#{placeholder}" if label == 'bearer_header' && match =~ /\s/
72
+
73
+ placeholder
74
+ end
75
+ end
76
+ end
77
+ end