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