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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'provider'
4
+ require_relative 'providers/llmlite'
5
+ require_relative 'providers/gateway'
6
+
7
+ module Vangrail
8
+ # The provider registry, in the order `Provider.resolve` tries it.
9
+ #
10
+ # Order is the whole policy: local before shared. A proxy on the loopback
11
+ # costs nothing per call, keeps rail traffic on the machine, and needs no
12
+ # shared credential, so an application with one running should use it without
13
+ # being told to.
14
+ #
15
+ # Only vendor-neutral entries are built in. A hostname compiled into this gem
16
+ # is an endpoint every installation inherits whether it can reach it or not,
17
+ # and a credential path compiled in is worse, because it publishes where
18
+ # somebody's secrets live. A shared gateway is therefore registered by the
19
+ # application that has one, or described by environment:
20
+ #
21
+ # Vangrail::Providers.register_gateway(name: 'hub', base_url: '...', ...)
22
+ # GUARDRAILS_GATEWAY_API_BASE=... GUARDRAILS_GATEWAY_API_KEY=...
23
+ #
24
+ # An endpoint needed for a single run needs no registration at all:
25
+ # GUARDRAILS_API_BASE with GUARDRAILS_API_KEY beats the whole registry.
26
+ module Providers
27
+ module_function
28
+
29
+ def install!(env = ENV)
30
+ Provider.registry.clear
31
+ Provider.register(Llmlite.provider(env))
32
+ register_environment_gateway(env)
33
+ registered_specs.each { |spec| Provider.register(Gateway.provider(spec, env)) }
34
+ Provider.registry
35
+ end
36
+
37
+ # Gateways an application asked for, in the order it asked.
38
+ def registered_specs
39
+ @registered_specs ||= []
40
+ end
41
+
42
+ # Registers a shared gateway and returns its Provider. Registering a name
43
+ # twice replaces it, so reloading an application is not a duplicate.
44
+ def register_gateway(name:, base_url:, models: {}, guard_preset: nil, key_env: nil,
45
+ file_env: nil, pass_env: nil, key_file: nil, pass_entry: nil, env: ENV)
46
+ spec = Gateway::Spec.new(
47
+ name: name.to_s, base_url: base_url, models: models, guard_preset: guard_preset,
48
+ key_env: key_env, file_env: file_env, pass_env: pass_env,
49
+ key_file: key_file, pass_entry: pass_entry
50
+ )
51
+ registered_specs.reject! { |s| s.name == spec.name }
52
+ registered_specs << spec
53
+ Provider.register(Gateway.provider(spec, env))
54
+ end
55
+
56
+ def register_environment_gateway(env = ENV)
57
+ spec = Gateway.from_environment(env)
58
+ return nil unless spec
59
+
60
+ Provider.register(Gateway.provider(spec, env))
61
+ end
62
+
63
+ # Forgets registered gateways along with any cached credential, so a test
64
+ # that points a lookup at nothing gets what it asked for.
65
+ def reset!
66
+ @registered_specs = []
67
+ install!
68
+ end
69
+ end
70
+ end
71
+
72
+ Vangrail::Providers.install!
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'result'
4
+
5
+ module Vangrail
6
+ # The whole rail protocol: a name, the sides it applies to, and `call`.
7
+ #
8
+ # class ShoutRail < Vangrail::Rail
9
+ # def call(text, _context)
10
+ # return pass if text == text.downcase
11
+ #
12
+ # modify(text.downcase, reason: 'lowered')
13
+ # end
14
+ # end
15
+ #
16
+ # Deliberately not a DSL. A rail is an object with one method, so a Ruby
17
+ # application can write one in five lines, test it without a network, and put
18
+ # it in the same ordered list as the model-backed ones.
19
+ class Rail
20
+ # Three sides, not two. `:context` is text the application retrieved and is
21
+ # about to put in a prompt: a wiki page, a search result, a file. It is the
22
+ # side an attacker usually reaches without touching the application at all,
23
+ # and a stack that checks only what the user typed and what the model
24
+ # answered never looks at it.
25
+ SIDES = %i[input context output].freeze
26
+
27
+ # What a rail gets by default. Context is opt-in per rail, because a rail
28
+ # written to judge a question is rarely the right one to judge a document.
29
+ DEFAULT_SIDES = %i[input output].freeze
30
+
31
+ attr_reader :name, :sides
32
+
33
+ def initialize(name: nil, sides: DEFAULT_SIDES)
34
+ @name = (name || default_name).to_s
35
+ @sides = Array(sides).map(&:to_sym)
36
+ unknown = @sides - SIDES
37
+ raise ArgumentError, "unknown side(s): #{unknown.join(', ')}" unless unknown.empty?
38
+ end
39
+
40
+ def applies_to?(side)
41
+ sides.include?(side.to_sym)
42
+ end
43
+
44
+ # Returns a Result. `context` is a hash the engine threads through:
45
+ # :side, :user_input, :passages, :history, plus anything a caller adds.
46
+ def call(_text, _context)
47
+ raise NotImplementedError, "#{self.class} must implement #call"
48
+ end
49
+
50
+ # Does this rail need the network. Used to report a posture and to let a
51
+ # caller build a model-free engine on purpose.
52
+ def offline?
53
+ false
54
+ end
55
+
56
+ # A rail that stands in for one that could not be built, rather than a rail
57
+ # that ran. The engine prefers the reason from something that actually ran
58
+ # when both are uncertain, because "the endpoint refused the connection" is
59
+ # more actionable than "no endpoint was resolved".
60
+ def placeholder?
61
+ false
62
+ end
63
+
64
+ def to_s
65
+ name
66
+ end
67
+
68
+ private
69
+
70
+ def pass(**kwargs)
71
+ Result.passed(rail: name, **kwargs)
72
+ end
73
+
74
+ def modify(content, **kwargs)
75
+ Result.modified(rail: name, content: content, **kwargs)
76
+ end
77
+
78
+ def block(**kwargs)
79
+ Result.blocked(rail: name, **kwargs)
80
+ end
81
+
82
+ # A rail that could not reach a decision allows the text and says so. It
83
+ # must never look like a clean check.
84
+ def unchecked(reason)
85
+ Result.unchecked(rail: name, reason: reason)
86
+ end
87
+
88
+ def default_name
89
+ self.class.name.to_s.split('::').last
90
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Refuses text too large to be a question.
8
+ #
9
+ # The cost of answering is paid by whoever runs the endpoint, and a public
10
+ # desk gives anybody a way to spend it. A megabyte pasted into the box is
11
+ # not a question: it is a bill, and on a metered endpoint it is somebody
12
+ # else's bill. The same size also dilutes whatever the instructions said,
13
+ # which is the mechanism the many-shot work measured, so the limit is worth
14
+ # having twice over.
15
+ #
16
+ # Characters rather than tokens, because counting tokens means shipping a
17
+ # tokeniser, and a tokeniser is a dependency and a model-specific one. The
18
+ # ratio is close enough for a limit: roughly four characters a token for
19
+ # English prose, less for code.
20
+ #
21
+ # Rails::Budget.new(max_characters: 8_000)
22
+ #
23
+ # A separate, larger limit applies to retrieved documents, which are
24
+ # legitimately longer than anything a reader types and are usually clipped
25
+ # by the retrieval step already. Setting it to nil turns that side off.
26
+ #
27
+ # Blocks rather than truncating. A truncated question is a different
28
+ # question, and answering a different question well is worse than saying
29
+ # the box has a limit.
30
+ class Budget < Rail
31
+ DEFAULT_INPUT = 8_000
32
+ DEFAULT_CONTEXT = 60_000
33
+
34
+ attr_reader :max_characters, :max_context
35
+
36
+ def initialize(max_characters: DEFAULT_INPUT, max_context: DEFAULT_CONTEXT,
37
+ name: 'budget', sides: %i[input context])
38
+ super(name: name, sides: sides)
39
+ @max_characters = max_characters
40
+ @max_context = max_context
41
+ end
42
+
43
+ def offline?
44
+ true
45
+ end
46
+
47
+ def cache_key(text, context)
48
+ "#{context[:side]}:#{text.to_s.length}"
49
+ end
50
+
51
+ def call(text, context)
52
+ limit = context[:side] == :context ? max_context : max_characters
53
+ return pass if limit.nil?
54
+
55
+ size = text.to_s.length
56
+ return pass if size <= limit
57
+
58
+ block(categories: ['over_budget'],
59
+ reason: "#{size} characters, over the #{limit} allowed for #{context[:side]}")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative '../rail'
5
+
6
+ module Vangrail
7
+ module Rails
8
+ # A marker in the prompt that must never appear anywhere else.
9
+ #
10
+ # Every other rail that cares about the system prompt is guessing: it reads
11
+ # the question for a shape that looks like an extraction attempt, or reads
12
+ # the answer for something that looks like an instruction. This one does
13
+ # not have to guess. The application puts a random string in the prompt, and
14
+ # if that string ever comes back out, the prompt came out with it.
15
+ #
16
+ # canary = Vangrail::Rails::Canary.generate
17
+ # prompt = "#{canary}\n#{system_prompt}"
18
+ # engine = Vangrail::Engine.new(output: [Vangrail::Rails::Canary.new(tokens: canary)])
19
+ #
20
+ # It is exact rather than clever, and that is the whole value: no false
21
+ # positives are possible on a random 16-character token, so it can block
22
+ # outright where a heuristic could only warn.
23
+ #
24
+ # It runs on the input side too. A question containing the canary means the
25
+ # reader already has the prompt from somewhere, which is worth knowing even
26
+ # though it is too late to prevent.
27
+ #
28
+ # What it cannot see is a paraphrase. A model asked to summarise its
29
+ # instructions rather than repeat them leaks the content and not the token,
30
+ # and this rail passes. It is one exact check, not a disclosure defence, and
31
+ # the policy rails still have to do their job.
32
+ class Canary < Rail
33
+ LENGTH = 16
34
+
35
+ def self.generate(length: LENGTH)
36
+ "canary-#{SecureRandom.alphanumeric(length)}"
37
+ end
38
+
39
+ attr_reader :tokens
40
+
41
+ def initialize(tokens:, name: 'canary', sides: %i[input output])
42
+ super(name: name, sides: sides)
43
+ @tokens = Array(tokens).map(&:to_s).reject(&:empty?)
44
+ raise ArgumentError, 'a canary rail needs at least one token' if @tokens.empty?
45
+ end
46
+
47
+ def offline?
48
+ true
49
+ end
50
+
51
+ def cache_key(text, _context)
52
+ text
53
+ end
54
+
55
+ def call(text, context)
56
+ body = text.to_s
57
+ # Formatting is not concealment, but a model that writes the token with
58
+ # a line break or a backtick in it has still leaked it, so the
59
+ # comparison ignores anything that is not part of the token itself.
60
+ flat = body.gsub(/[^A-Za-z0-9-]/, '')
61
+ found = tokens.select { |t| body.include?(t) || flat.include?(t.gsub(/[^A-Za-z0-9-]/, '')) }
62
+ return pass if found.empty?
63
+
64
+ block(categories: ['canary'], reason: reason_for(context[:side]))
65
+ end
66
+
67
+ private
68
+
69
+ def reason_for(side)
70
+ return 'the question contains a prompt canary, so the prompt has already leaked' if side == :input
71
+
72
+ 'the answer contains the prompt canary'
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../colang/interpreter'
4
+ require_relative '../rail'
5
+
6
+ module Vangrail
7
+ module Rails
8
+ # A Colang flow as a rail.
9
+ #
10
+ # This is what lets a configuration folder written for the Python toolkit
11
+ # run here: `rails.input.flows: [self check input]` becomes one of these,
12
+ # the flow executes in Ruby, and its `bot refuse to respond` / `stop`
13
+ # becomes a blocked Result carrying the refusal text.
14
+ #
15
+ # Not memoizable by default. A flow can call any registered action, and this
16
+ # class cannot know whether one of them reads the passages or the history.
17
+ class ColangFlow < Rail
18
+ attr_reader :flow_name, :interpreter
19
+
20
+ def initialize(flow_name:, program:, actions:, name: nil, sides: Rail::SIDES)
21
+ super(name: name || flow_name, sides: sides)
22
+ @flow_name = flow_name
23
+ @interpreter = Colang::Interpreter.new(program: program, actions: actions)
24
+ end
25
+
26
+ def cache_key(_text, _context)
27
+ nil
28
+ end
29
+
30
+ def call(text, context)
31
+ outcome = interpreter.run(flow_name, context.merge(text: text))
32
+ case outcome.status
33
+ when :blocked then block(content: outcome.content, reason: outcome.reason || flow_name)
34
+ when :modified then modify(outcome.content, reason: outcome.reason || flow_name)
35
+ else pass
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../rail'
4
+
5
+ module Vangrail
6
+ module Rails
7
+ # Watches what happens after a refusal.
8
+ #
9
+ # The multi-turn attacks work because the guardrail forgets. A request is
10
+ # refused, the next message is the same request with the objectionable word
11
+ # removed, and the rail reads it as a fresh question because that is all it
12
+ # has ever been given. Repeat until something gets through. The published
13
+ # multi-turn methods differ in how they choose the rewrite, and they share
14
+ # that one assumption: that turn N+1 is judged without turn N.
15
+ #
16
+ # So this rail judges the sequence rather than the message. It reads
17
+ # `:history` from the context, which a Conversation fills in, and it has
18
+ # exactly two things to say:
19
+ #
20
+ # retry_after_refusal the last question was refused, and this one is
21
+ # that question again: mostly the same words, or a
22
+ # bare reference back to it, or a reframing opener
23
+ # ("hypothetically", "just for research") on top of
24
+ # it
25
+ # repeated_refusals several refusals in a short window, whatever this
26
+ # particular message says
27
+ #
28
+ # Both are cheap and neither is clever. A caller that never passes
29
+ # `:history` gets a pass with an honest `certain?` of false, because a rail
30
+ # that reads history and was handed none has not checked anything. A caller
31
+ # that passes an empty one gets a certain pass: an empty dialogue is an
32
+ # answer rather than a missing one.
33
+ #
34
+ # The limit is worth stating: a genuine crescendo never triggers a refusal
35
+ # at all until the last turn, and this rail sees nothing until one happens.
36
+ # It raises the cost of the cheap version of the attack, where the attacker
37
+ # probes until something lands. Judging a dialogue that has never been
38
+ # refused needs a model reading the trajectory, which is a different rail
39
+ # and a round trip.
40
+ class Escalation < Rail
41
+ # A retry does not have to be a paraphrase. It can be a pointer.
42
+ REFERENCE_BACK = /
43
+ \A[^.?!]{0,60}\b(?:as\s+i\s+(?:said|asked|mentioned)|like\s+i\s+(?:said|asked)|
44
+ (?:the|my)\s+(?:previous|last|earlier)\s+(?:question|request|message)|
45
+ try\s+again|answer\s+(?:it|that|the\s+question)\s+anyway|
46
+ just\s+(?:answer|tell|say)|come\s+on|continue|go\s+on|please\s+continue)\b
47
+ /xi
48
+
49
+ # The openers that exist to relabel a refused request as something else.
50
+ REFRAMING = /
51
+ \b(?:hypothetically|in\s+theory|for\s+(?:a\s+)?(?:friend|research|a\s+paper|
52
+ educational\s+purposes|academic\s+purposes)|purely\s+(?:academic|hypothetical)|
53
+ what\s+if\s+i\s+(?:told\s+you|said)|imagine\s+(?:that\s+)?you|
54
+ let\s+me\s+rephrase|to\s+(?:re)?phrase\s+(?:it|that)\s+differently|
55
+ you\s+misunderstood|that\s+is\s+not\s+what\s+i\s+(?:meant|asked))\b
56
+ /xi
57
+
58
+ STOP = %w[
59
+ the a an and or but is are was were be been being to of in on at for with
60
+ from by as it its this that these those i you he she they we me my your do
61
+ does did how what why when where can could would should will shall may
62
+ might must not no yes if then than so about into over under please
63
+ ].freeze
64
+
65
+ attr_reader :overlap, :window, :tolerance
66
+
67
+ # `overlap` is the share of this question's content words that also
68
+ # appeared in the refused one. Three fifths is where the corpus put it: a
69
+ # rewrite keeps the nouns and changes the verb, so it lands near two
70
+ # thirds, while a genuine follow-up on the same subject shares one or two
71
+ # words out of seven. Higher and the measured rewrites walk through;
72
+ # lower and one refusal makes the topic unaskable, which ends the
73
+ # conversation rather than the attack.
74
+ def initialize(overlap: 0.6, window: 6, tolerance: 2, name: 'escalation', sides: [:input])
75
+ super(name: name, sides: sides)
76
+ @overlap = overlap
77
+ @window = window
78
+ @tolerance = tolerance
79
+ end
80
+
81
+ def offline?
82
+ true
83
+ end
84
+
85
+ # Not memoizable: the same question means different things depending on
86
+ # what came before it, which is the entire premise of the rail.
87
+ def cache_key(_text, _context)
88
+ nil
89
+ end
90
+
91
+ def call(text, context)
92
+ # A caller that never passes :history is not threading a dialogue, and
93
+ # this rail has not checked anything: say so. A caller that passes an
94
+ # empty one is threading a dialogue that has just started, which is a
95
+ # real answer rather than a missing one. The distinction matters
96
+ # because an uncertain pass here would otherwise be the first uncertain
97
+ # result in every single-turn engine, and would mask the reason a
98
+ # model rail actually failed.
99
+ return unchecked('no history was provided, so nothing was compared') unless context.key?(:history)
100
+
101
+ history = Array(context[:history])
102
+ return pass if history.empty?
103
+
104
+ refused = history.select { |t| user?(t) && t[:blocked] }
105
+ return pass if refused.empty?
106
+
107
+ recent = history.last(window).count { |t| user?(t) && t[:blocked] }
108
+ if recent > tolerance
109
+ return block(categories: ['repeated_refusals'],
110
+ reason: "#{recent} refused questions in the last #{window} turns")
111
+ end
112
+
113
+ retry_of(text.to_s, refused.last)
114
+ end
115
+
116
+ private
117
+
118
+ def retry_of(text, last_refusal)
119
+ return pass if last_refusal.nil?
120
+
121
+ shape = retry_shape(text, last_refusal[:text].to_s)
122
+ return pass if shape.nil?
123
+
124
+ block(categories: ['retry_after_refusal', shape].compact,
125
+ reason: "the previous question was refused and this one is #{describe(shape)}")
126
+ end
127
+
128
+ # Ordered by how much it says. A paraphrase is the strongest signal
129
+ # because it needs no interpretation: the same content words in a new
130
+ # sentence, one turn after a refusal.
131
+ def retry_shape(text, previous)
132
+ return 'paraphrase' if similar?(text, previous)
133
+ return 'reframed' if text.match?(REFRAMING) && shares_topic?(text, previous)
134
+ return 'reference_back' if text.match?(REFERENCE_BACK)
135
+
136
+ nil
137
+ end
138
+
139
+ def describe(shape)
140
+ case shape
141
+ when 'paraphrase' then 'the same question again'
142
+ when 'reframed' then 'the same question with a reframing opener'
143
+ else 'a request to answer it anyway'
144
+ end
145
+ end
146
+
147
+ def similar?(text, previous)
148
+ now = content_words(text)
149
+ before = content_words(previous)
150
+ return false if now.empty? || before.empty?
151
+ # A one-word follow-up is not evidence of anything.
152
+ return false if now.size < 3
153
+
154
+ (now & before).size.to_f / now.size >= overlap
155
+ end
156
+
157
+ # Enough shared vocabulary to be about the same thing, without being the
158
+ # same sentence. A reframing opener on an unrelated question is just a
159
+ # question.
160
+ def shares_topic?(text, previous)
161
+ now = content_words(text)
162
+ before = content_words(previous)
163
+ return false if now.empty? || before.empty?
164
+
165
+ now.intersect?(before)
166
+ end
167
+
168
+ def content_words(text)
169
+ text.to_s.downcase.scan(/[a-z0-9_-]{2,}/) - STOP
170
+ end
171
+
172
+ def user?(turn)
173
+ role = turn[:role] || turn['role']
174
+ role.nil? || role.to_sym == :user
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../rail'
5
+
6
+ module Vangrail
7
+ module Rails
8
+ # Strips outbound URLs an answer has no business emitting.
9
+ #
10
+ # This is the rail for the attack that does not need the reader to do
11
+ # anything. A poisoned page tells the model to end its answer with a
12
+ # markdown image whose URL carries the conversation in a query parameter;
13
+ # the chat client renders markdown, so it fetches that URL by itself, and
14
+ # the data is gone before anybody has read a word. The same trick with a
15
+ # link needs one click, which a reader who trusts the assistant will give
16
+ # it. Every shipped assistant that rendered markdown had this, and the fix
17
+ # each of them landed on was the same: decide which hosts may be fetched,
18
+ # and refuse the rest.
19
+ #
20
+ # So the rule here is an allowlist, and the default allowlist is empty,
21
+ # because a documentation assistant answering from a handbook has exactly
22
+ # one set of hosts worth linking to and the application knows what they are.
23
+ #
24
+ # Rails::Exfiltration.new(allow_hosts: %w[docs.example.org example.org])
25
+ #
26
+ # Images are stricter than links: an image is fetched without consent, so a
27
+ # host being allowlisted for links does not make it a place to auto-load
28
+ # from unless `allow_images` names it too.
29
+ #
30
+ # It redacts rather than blocks. An answer with a bad link is a useful
31
+ # answer with one bad span in it, and throwing away the help teaches readers
32
+ # that the guardrail is the problem. The link text survives; the target does
33
+ # not.
34
+ class Exfiltration < Rail
35
+ # Kept out of prose because a bare marker in the middle of a sentence
36
+ # reads as an editing artefact, which is exactly what it is.
37
+ PLACEHOLDER = '[link removed]'
38
+ IMAGE_PLACEHOLDER = '[image removed]'
39
+
40
+ # Markdown image, markdown link, bare HTML img/a, and anything with an
41
+ # explicit scheme that is not http(s). Autolinks in angle brackets count:
42
+ # some renderers fetch previews for them.
43
+ IMAGE = /!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?[^)]*\)/
44
+ LINK = /(?<!!)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?[^)]*\)/
45
+ HTML_IMAGE = /<img\b[^>]*?\bsrc\s*=\s*["']?([^"'>\s]+)[^>]*>/i
46
+ HTML_LINK = /<a\b[^>]*?\bhref\s*=\s*["']?([^"'>\s]+)[^>]*>(.*?)<\/a>/im
47
+ AUTOLINK = /<((?:https?|data|file|ftp):\/\/[^>\s]+)>/i
48
+
49
+ # A URL is suspicious on its own terms when it carries a payload: a long
50
+ # query string, percent-encoded text, or a base64 run. An allowlisted host
51
+ # with a hundred characters of query is still worth naming, because that
52
+ # is what the exfiltration looks like when the attacker knows the
53
+ # allowlist.
54
+ PAYLOAD = /[?#].{40,}/
55
+ ENCODED = /(?:%[0-9A-Fa-f]{2}){6,}|[A-Za-z0-9+\/]{40,}={0,2}/
56
+
57
+ attr_reader :allow_hosts, :allow_images, :placeholder, :max_query
58
+
59
+ def initialize(allow_hosts: [], allow_images: nil, placeholder: PLACEHOLDER,
60
+ max_query: 40, name: 'exfiltration', sides: [:output])
61
+ super(name: name, sides: sides)
62
+ @allow_hosts = normalise(allow_hosts)
63
+ # nil means "the same hosts as links". An empty array means no images at
64
+ # all, which is the safe reading of an application that never asked.
65
+ @allow_images = allow_images.nil? ? @allow_hosts : normalise(allow_images)
66
+ @placeholder = placeholder
67
+ @max_query = max_query
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
+ cleaned = strip_all(body, found)
82
+ return pass if found.empty?
83
+
84
+ modify(cleaned, categories: found.uniq,
85
+ reason: "removed #{found.uniq.join(', ')}")
86
+ end
87
+
88
+ # Whether this rail would leave the URL alone. Public because a caller
89
+ # rendering its own links wants the same answer without a Result.
90
+ def allowed?(url, image: false)
91
+ host = host_of(url)
92
+ return false if host.nil?
93
+
94
+ list = image ? allow_images : allow_hosts
95
+ return false unless list.any? { |h| host == h || host.end_with?(".#{h}") }
96
+
97
+ !payload?(url)
98
+ end
99
+
100
+ private
101
+
102
+ # The whole match is passed along rather than read back from
103
+ # Regexp.last_match: a block shares its enclosing frame's match data, and a
104
+ # method called from that block has its own, so a helper reading it would
105
+ # see nothing.
106
+ def strip_all(body, found)
107
+ m = ->(n) { Regexp.last_match(n) }
108
+ out = body.gsub(IMAGE) { redact_image(m[0], m[1], m[2], found) }
109
+ out = out.gsub(LINK) { redact_link(m[0], m[1], m[2], found) }
110
+ out = out.gsub(HTML_IMAGE) { redact_image(m[0], '', m[1], found) }
111
+ out = out.gsub(HTML_LINK) { redact_link(m[0], m[2], m[1], found) }
112
+ out.gsub(AUTOLINK) { redact_link(m[0], nil, m[1], found) }
113
+ end
114
+
115
+ def redact_image(whole, alt, url, found)
116
+ return whole if allowed?(url, image: true)
117
+
118
+ found << category(url, image: true)
119
+ alt.to_s.empty? ? IMAGE_PLACEHOLDER : "#{alt} #{IMAGE_PLACEHOLDER}"
120
+ end
121
+
122
+ # The words stay, the destination goes. A reader still sees what the
123
+ # answer meant to point at and can search for it.
124
+ def redact_link(whole, label, url, found)
125
+ return whole if allowed?(url)
126
+
127
+ found << category(url)
128
+ text = label.to_s.strip
129
+ text.empty? ? placeholder : "#{text} #{placeholder}"
130
+ end
131
+
132
+ def category(url, image: false)
133
+ return image ? 'foreign_image' : 'foreign_link' unless payload?(url)
134
+
135
+ image ? 'image_payload' : 'link_payload'
136
+ end
137
+
138
+ # Long query strings and encoded runs are the payload itself. Checked
139
+ # before the allowlist matters, so an allowlisted host cannot be used as
140
+ # an open redirect for the same trick.
141
+ def payload?(url)
142
+ query = url.to_s[/[?#].*/].to_s
143
+ return true if query.length > max_query
144
+ return true if query.match?(PAYLOAD)
145
+
146
+ query.match?(ENCODED)
147
+ end
148
+
149
+ # Anything without a parseable http(s) host is refused, which covers
150
+ # data:, file:, javascript:, protocol-relative //evil, and the malformed
151
+ # cases a renderer might still resolve.
152
+ def host_of(url)
153
+ uri = URI.parse(url.to_s.strip)
154
+ return nil unless %w[http https].include?(uri.scheme)
155
+
156
+ uri.host&.downcase
157
+ rescue URI::InvalidURIError
158
+ nil
159
+ end
160
+
161
+ def normalise(hosts)
162
+ Array(hosts).map { |h| h.to_s.downcase.sub(/\Ahttps?:\/\//, '').split('/').first.to_s }
163
+ .reject(&:empty?).freeze
164
+ end
165
+ end
166
+ end
167
+ end