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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+ require_relative 'ast'
5
+
6
+ module Vangrail
7
+ module Colang
8
+ # Reads the Colang 1.0 subset that rail flows are written in.
9
+ #
10
+ # define flow self check input
11
+ # $allowed = execute self_check_input
12
+ # if not $allowed
13
+ # bot refuse to respond
14
+ # stop
15
+ #
16
+ # define bot refuse to respond
17
+ # "I'm sorry, I can't respond to that."
18
+ #
19
+ # That subset covers the input and output rails that ship with the toolkit
20
+ # and the ones people write. Dialog flows with user-intent matching are not
21
+ # supported, and a file using them raises rather than loading with the
22
+ # matching quietly missing: a guardrail that half-loads is a guardrail that
23
+ # reports checks it is not running.
24
+ #
25
+ # Indentation defines blocks. Tabs are refused, because a file mixing tabs
26
+ # and spaces would otherwise parse into a different program than it looks.
27
+ class Parser
28
+ Line = Struct.new(:indent, :text, :number, keyword_init: true)
29
+
30
+ def self.parse(source, filename: nil)
31
+ new(source, filename: filename).parse
32
+ end
33
+
34
+ def initialize(source, filename: nil)
35
+ @source = source.to_s
36
+ @filename = filename
37
+ @flows = {}
38
+ @bot_messages = {}
39
+ @user_messages = {}
40
+ end
41
+
42
+ def parse
43
+ lines = significant_lines
44
+ index = 0
45
+ index = definition(lines, index) while index < lines.length
46
+ Program.new(flows: @flows, bot_messages: @bot_messages, user_messages: @user_messages)
47
+ end
48
+
49
+ private
50
+
51
+ def significant_lines
52
+ @source.lines.each_with_index.filter_map do |raw, i|
53
+ number = i + 1
54
+ raise error('tabs are not allowed in Colang indentation', number) if raw.start_with?("\t")
55
+
56
+ text = raw.rstrip
57
+ stripped = text.strip
58
+ next if stripped.empty? || stripped.start_with?('#')
59
+
60
+ Line.new(indent: text[/\A */].length, text: stripped, number: number)
61
+ end
62
+ end
63
+
64
+ # One `define ...` header plus everything indented under it.
65
+ def definition(lines, index)
66
+ header = lines[index]
67
+ raise error("expected a `define` block, got #{header.text.inspect}", header.number) unless
68
+ header.text.start_with?('define ')
69
+
70
+ body, next_index = block(lines, index + 1, header.indent)
71
+ case header.text
72
+ when /\Adefine (flow|subflow)\s+(.+)\z/
73
+ name = Regexp.last_match(2).strip
74
+ @flows[name] =
75
+ Flow.new(name: name, body: statements(body), subflow: Regexp.last_match(1) == 'subflow')
76
+ when /\Adefine bot\s+(.+)\z/
77
+ @bot_messages[Regexp.last_match(1).strip] = strings(body)
78
+ when /\Adefine user\s+(.+)\z/
79
+ @user_messages[Regexp.last_match(1).strip] = strings(body)
80
+ else
81
+ raise error("unsupported definition #{header.text.inspect}", header.number)
82
+ end
83
+ next_index
84
+ end
85
+
86
+ # Every line indented deeper than `indent`, and where to resume.
87
+ def block(lines, index, indent)
88
+ body = []
89
+ while index < lines.length && lines[index].indent > indent
90
+ body << lines[index]
91
+ index += 1
92
+ end
93
+ [body, index]
94
+ end
95
+
96
+ def statements(lines)
97
+ result = []
98
+ index = 0
99
+ while index < lines.length
100
+ statement, index = statement(lines, index)
101
+ result << statement
102
+ end
103
+ result
104
+ end
105
+
106
+ def statement(lines, index)
107
+ line = lines[index]
108
+ case line.text
109
+ when /\A\$(\w+)\s*=\s*(.+)\z/
110
+ [Assign.new(variable: Regexp.last_match(1), expression: expression(Regexp.last_match(2), line)),
111
+ index + 1]
112
+ when /\Aexecute\s+(.+)\z/
113
+ [action_call(Regexp.last_match(1), line), index + 1]
114
+ when /\Abot\s+(.+)\z/
115
+ [Bot.new(message: Regexp.last_match(1).strip), index + 1]
116
+ when /\Astop\z/
117
+ [Stop.new(reason: nil), index + 1]
118
+ when /\Aif\s+(.+)\z/
119
+ # $~ is frame-local, so the captured condition is passed rather than
120
+ # read again inside the callee.
121
+ conditional(lines, index, line, Regexp.last_match(1))
122
+ when /\Aelse\z/
123
+ raise error('`else` without a matching `if`', line.number)
124
+ else
125
+ raise error("unsupported statement #{line.text.inspect}", line.number)
126
+ end
127
+ end
128
+
129
+ def conditional(lines, index, line, condition_text)
130
+ condition = condition(condition_text, line)
131
+ then_lines, index = block(lines, index + 1, line.indent)
132
+ else_lines = []
133
+ if index < lines.length && lines[index].text == 'else' && lines[index].indent == line.indent
134
+ else_lines, index = block(lines, index + 1, lines[index].indent)
135
+ end
136
+ [If.new(condition: condition, then_body: statements(then_lines), else_body: statements(else_lines)),
137
+ index]
138
+ end
139
+
140
+ def expression(text, line)
141
+ return action_call(Regexp.last_match(1), line) if text =~ /\Aexecute\s+(.+)\z/
142
+ return Literal.new(value: unquote(text)) if quoted?(text)
143
+ return Var.new(name: Regexp.last_match(1)) if text =~ /\A\$(\w+)\z/
144
+
145
+ raise error("unsupported expression #{text.inspect}", line.number)
146
+ end
147
+
148
+ def action_call(text, line)
149
+ name, args = text.match(/\A([\w.]+)\s*(?:\((.*)\))?\s*\z/)&.captures
150
+ raise error("unsupported action call #{text.inspect}", line.number) unless name
151
+
152
+ Execute.new(action: name, arguments: arguments(args, line))
153
+ end
154
+
155
+ # key="value", key=$var, key=42. Positional arguments are refused: an
156
+ # action here is a Ruby method taking a keyword hash.
157
+ def arguments(text, line)
158
+ return {} if text.nil? || text.strip.empty?
159
+
160
+ text.split(/,(?=(?:[^"]*"[^"]*")*[^"]*\z)/).to_h do |pair|
161
+ key, value = pair.split('=', 2).map { |s| s.to_s.strip }
162
+ raise error("argument #{pair.inspect} needs a name", line.number) if value.nil? || key.empty?
163
+
164
+ [key, argument_value(value, line)]
165
+ end
166
+ end
167
+
168
+ def argument_value(value, line)
169
+ return unquote(value) if quoted?(value)
170
+ return Var.new(name: Regexp.last_match(1)) if value =~ /\A\$(\w+)\z/
171
+ return value.to_i if value.match?(/\A-?\d+\z/)
172
+ return true if %w[True true].include?(value)
173
+ return false if %w[False false].include?(value)
174
+
175
+ raise error("unsupported argument value #{value.inspect}", line.number)
176
+ end
177
+
178
+ def condition(text, line)
179
+ text = text.strip
180
+ return Not.new(expression: condition(Regexp.last_match(1), line)) if text =~ /\Anot\s+(.+)\z/
181
+ if text =~ /\A(.+?)\s*(==|!=)\s*(.+)\z/
182
+ return Compare.new(
183
+ left: condition(Regexp.last_match(1), line),
184
+ operator: Regexp.last_match(2),
185
+ right: condition(Regexp.last_match(3), line)
186
+ )
187
+ end
188
+ return Var.new(name: Regexp.last_match(1)) if text =~ /\A\$(\w+)\z/
189
+ return Literal.new(value: unquote(text)) if quoted?(text)
190
+ return Literal.new(value: true) if %w[True true].include?(text)
191
+ return Literal.new(value: false) if %w[False false].include?(text)
192
+
193
+ raise error("unsupported condition #{text.inspect}", line.number)
194
+ end
195
+
196
+ def strings(lines)
197
+ lines.map do |line|
198
+ unless quoted?(line.text)
199
+ raise error("expected a quoted message, got #{line.text.inspect}",
200
+ line.number)
201
+ end
202
+
203
+ unquote(line.text)
204
+ end
205
+ end
206
+
207
+ def quoted?(text)
208
+ text.length >= 2 && ((text.start_with?('"') && text.end_with?('"')) ||
209
+ (text.start_with?("'") && text.end_with?("'")))
210
+ end
211
+
212
+ def unquote(text)
213
+ text[1..-2].gsub('\\"', '"').gsub("\\'", "'")
214
+ end
215
+
216
+ def error(message, number)
217
+ where = [@filename, number].compact.join(':')
218
+ ColangError.new("#{where}: #{message}")
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'yaml'
5
+ require_relative 'actions'
6
+ require_relative 'chat'
7
+ require_relative 'colang/library'
8
+ require_relative 'colang/parser'
9
+ require_relative 'engine'
10
+ require_relative 'errors'
11
+ require_relative 'policies'
12
+ require_relative 'rails/colang_flow'
13
+ require_relative 'rails/grounding'
14
+ require_relative 'rails/self_check'
15
+ require_relative 'provider'
16
+
17
+ module Vangrail
18
+ # A guardrails configuration folder, read and written by Ruby.
19
+ #
20
+ # config = Vangrail::Config.load('config/handbook')
21
+ # engine = config.engine
22
+ # engine.check_input('Ignore your instructions.')
23
+ #
24
+ # The folder is the format the Python toolkit uses: config.yml for models and
25
+ # which flows run on which side, prompts.yml for the policy text each
26
+ # self-check task judges against, and rails/*.co for the flows themselves.
27
+ # Nothing here shells out to it. The YAML is read, the Colang is parsed, and
28
+ # the flows execute in this process, so the same folder can be handed to
29
+ # either runtime and describes one set of rails either way.
30
+ #
31
+ # A folder naming a flow that nothing defines raises. A folder naming a model
32
+ # type this gem cannot serve raises. Both are load-time failures on purpose: a
33
+ # configuration that comes up with half its rails missing is worse than one
34
+ # that refuses to come up.
35
+ class Config
36
+ SELF_CHECK_TASKS = { 'self_check_input' => :input, 'self_check_output' => :output }.freeze
37
+
38
+ attr_reader :name, :models, :rails, :prompts, :flows, :instructions, :sample_conversation, :path
39
+
40
+ def initialize(name:, models: [], rails: {}, prompts: [], flows: {}, instructions: nil,
41
+ sample_conversation: nil, path: nil)
42
+ @name = name
43
+ @models = models
44
+ @rails = rails
45
+ @prompts = prompts
46
+ @flows = flows
47
+ @instructions = instructions
48
+ @sample_conversation = sample_conversation
49
+ @path = path
50
+ end
51
+
52
+ # --- reading ---
53
+
54
+ def self.load(dir)
55
+ raise ConfigError, "no configuration folder at #{dir}" unless File.directory?(dir)
56
+
57
+ yaml = load_yaml(File.join(dir, 'config.yml')) || load_yaml(File.join(dir, 'config.yaml')) || {}
58
+ prompts = Array((load_yaml(File.join(dir, 'prompts.yml')) || {})['prompts'])
59
+ flows = Dir[File.join(dir, '**', '*.co')].to_h do |file|
60
+ [File.basename(file, '.co'), File.read(file)]
61
+ end
62
+
63
+ new(
64
+ name: File.basename(dir),
65
+ models: Array(yaml['models']),
66
+ rails: yaml['rails'] || {},
67
+ prompts: prompts,
68
+ flows: flows,
69
+ instructions: yaml['instructions'],
70
+ sample_conversation: yaml['sample_conversation'],
71
+ path: dir
72
+ )
73
+ end
74
+
75
+ def self.load_yaml(file)
76
+ return nil unless File.file?(file)
77
+
78
+ YAML.safe_load_file(file, aliases: true)
79
+ end
80
+
81
+ # --- running ---
82
+
83
+ # Every flow this configuration can execute: the ones it ships plus the
84
+ # built-ins it is allowed to name without defining.
85
+ def program
86
+ @program ||= flows.reduce(Colang::Library.program) do |acc, (file, source)|
87
+ acc.merge(Colang::Parser.parse(source, filename: "#{file}.co"))
88
+ end
89
+ end
90
+
91
+ def flow_names(side)
92
+ keys = [side.to_s]
93
+ # NeMo names the retrieved-document side `retrieval`. That is :context.
94
+ keys << 'retrieval' if side.to_sym == :context
95
+ keys.flat_map { |key| Array(rails.dig(key, 'flows')) }.map(&:to_s).uniq
96
+ end
97
+
98
+ def prompt_for(task)
99
+ entry = prompts.find { |p| p['task'].to_s == task.to_s }
100
+ entry && entry['content'].to_s
101
+ end
102
+
103
+ def model_for(type)
104
+ models.find { |m| m['type'].to_s == type.to_s }
105
+ end
106
+
107
+ # Builds the engine this configuration describes.
108
+ #
109
+ # `chat:` overrides where model-backed actions call, which is what tests and
110
+ # a caller with its own client pass. `actions:` adds or replaces actions by
111
+ # name, so a team's own check joins the built-ins without touching the gem.
112
+ def engine(provider: nil, chat: nil, actions: {}, on_error: :allow, cache: true)
113
+ registry = self_check_actions(provider, chat).merge(actions)
114
+ Engine.new(
115
+ input: rails_for(:input, registry),
116
+ context: rails_for(:context, registry),
117
+ output: rails_for(:output, registry),
118
+ on_error: on_error,
119
+ cache: cache
120
+ )
121
+ end
122
+
123
+ def rails_for(side, registry)
124
+ flow_names(side).map do |flow_name|
125
+ unless program.flow(flow_name)
126
+ raise ConfigError,
127
+ "#{name}: rails.#{side}.flows names #{flow_name.inspect}, which no .co file defines " \
128
+ "and which is not built in (#{Colang::Library.flow_names.join(', ')})"
129
+ end
130
+
131
+ Rails::ColangFlow.new(flow_name: flow_name, program: program, actions: registry, sides: [side])
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ # The three tasks a stock configuration expects, each backed by a rail this
138
+ # gem implements. A configuration that names none of them gets none of them.
139
+ def self_check_actions(provider, chat)
140
+ input = self_check_rail('self_check_input', :input, provider, chat)
141
+ output = self_check_rail('self_check_output', :output, provider, chat)
142
+ facts = grounding_rail(provider, chat)
143
+ Actions.from_rails(input: input, output: output, facts: facts)
144
+ end
145
+
146
+ def self_check_rail(task, side, provider, chat)
147
+ entry = model_for(task) || model_for('main')
148
+ return nil unless entry
149
+
150
+ Rails::SelfCheck.new(
151
+ name: task,
152
+ sides: [side],
153
+ policy: prompt_for(task),
154
+ model: entry['model'],
155
+ chat: chat,
156
+ provider: provider_for(entry, provider)
157
+ )
158
+ end
159
+
160
+ def grounding_rail(provider, chat)
161
+ entry = model_for('self_check_facts') || model_for('main')
162
+ return nil unless entry
163
+
164
+ Rails::Grounding.new(model: entry['model'], chat: chat, provider: provider_for(entry, provider))
165
+ end
166
+
167
+ # A model entry names its own endpoint through `parameters.base_url`, which
168
+ # is how the configuration format points at an OpenAI-compatible gateway.
169
+ # That wins over the caller's provider, because the folder is the thing
170
+ # under version control and the provider is ambient.
171
+ def provider_for(entry, provider)
172
+ base = entry.dig('parameters', 'base_url')
173
+ return provider if base.nil? || base.to_s.strip.empty?
174
+ return provider if provider && provider.base_url == base.to_s.sub(/\/+\z/, '')
175
+
176
+ key = provider&.api_key
177
+ Provider.new(name: entry['type'].to_s, base_url: base, models: { judge: entry['model'] },
178
+ key_resolver: key ? -> { key } : nil)
179
+ end
180
+
181
+ public
182
+
183
+ # --- writing ---
184
+
185
+ # A starting configuration for a provider. `engine: openai` with a base_url
186
+ # parameter is how the format names an OpenAI-compatible gateway, and this
187
+ # gem reads that field the same way, so one folder serves both runtimes.
188
+ def self.for_provider(provider, name: 'handbook', main_model: nil, judge_model: nil,
189
+ subject: 'a public documentation handbook')
190
+ base_url = provider.base_url
191
+ main_model ||= provider.model(:judge)
192
+ judge_model ||= provider.model(:judge)
193
+ new(
194
+ name: name,
195
+ models: [
196
+ model_entry('main', main_model, base_url),
197
+ model_entry('self_check_input', judge_model, base_url),
198
+ model_entry('self_check_output', judge_model, base_url)
199
+ ],
200
+ rails: {
201
+ 'input' => { 'flows' => ['self check input'] },
202
+ 'output' => { 'flows' => ['self check output'] }
203
+ },
204
+ prompts: [
205
+ { 'task' => 'self_check_input', 'content' => self_check_prompt(:input, subject) },
206
+ { 'task' => 'self_check_output', 'content' => self_check_prompt(:output, subject) }
207
+ ],
208
+ instructions: [
209
+ {
210
+ 'type' => 'general',
211
+ 'content' => "You answer questions about #{subject}. Every factual clause " \
212
+ 'comes from a supplied passage. Where the passages do not cover ' \
213
+ 'the question, say so.'
214
+ }
215
+ ]
216
+ )
217
+ end
218
+
219
+ def self.model_entry(type, model, base_url)
220
+ { 'type' => type, 'engine' => 'openai', 'model' => model, 'parameters' => { 'base_url' => base_url } }
221
+ end
222
+
223
+ # The self-check tasks read a Yes/No answer, so the policy is rendered as a
224
+ # question rather than with the JSON contract a policy judge uses.
225
+ def self.self_check_prompt(rail, subject)
226
+ policy = rail == :input ? Policies.input_policy(subject: subject) : Policies.output_policy(subject: subject)
227
+ body = policy.sub(Policies::ANSWER_CONTRACT, '').rstrip
228
+ slot = rail == :input ? '{{ user_input }}' : '{{ bot_response }}'
229
+ label = rail == :input ? 'User message' : 'Bot message'
230
+ <<~TXT
231
+ #{body}
232
+
233
+ #{label}: "#{slot}"
234
+
235
+ Question: Should the message be blocked (Yes or No)?
236
+ Answer:
237
+ TXT
238
+ end
239
+
240
+ def to_h
241
+ h = {}
242
+ h['models'] = models unless models.empty?
243
+ h['instructions'] = instructions if instructions
244
+ h['rails'] = rails unless rails.empty?
245
+ h['sample_conversation'] = sample_conversation if sample_conversation
246
+ h
247
+ end
248
+
249
+ def config_yaml
250
+ YAML.dump(to_h)
251
+ end
252
+
253
+ def prompts_yaml
254
+ YAML.dump('prompts' => prompts)
255
+ end
256
+
257
+ # Writes <root>/<name>/. Returns the directory it wrote.
258
+ def write!(root)
259
+ dir = File.join(root, name)
260
+ FileUtils.mkdir_p(dir)
261
+ File.write(File.join(dir, 'config.yml'), config_yaml)
262
+ File.write(File.join(dir, 'prompts.yml'), prompts_yaml) unless prompts.empty?
263
+ unless flows.empty?
264
+ FileUtils.mkdir_p(File.join(dir, 'rails'))
265
+ flows.each { |file, colang| File.write(File.join(dir, 'rails', "#{file}.co"), colang.to_s) }
266
+ end
267
+ dir
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'confusables_data'
4
+
5
+ module Vangrail
6
+ # Folds characters that imitate ASCII back to the ASCII they imitate.
7
+ #
8
+ # The data is generated from the Unicode confusables file (UTS #39) and lives
9
+ # in confusables_data.rb. This is the policy that uses it, kept apart so
10
+ # regenerating the table cannot overwrite a decision.
11
+ #
12
+ # The decision worth writing down is which words to fold. Folding everything
13
+ # turns a page of Russian into ASCII noise: "Кластер" becomes "Kлacтep",
14
+ # which is not Russian, not English, and not what anybody wrote. It happens
15
+ # to be harmless here, because a folded variant is only ever shown to a
16
+ # pattern and never to a reader, but it is the wrong thing to be doing and it
17
+ # widens the surface for an accidental match.
18
+ #
19
+ # What an imitation attack actually looks like is a *mixed* word: Latin
20
+ # letters with one or two lookalikes dropped in, so that "system" reads as
21
+ # "system" and is not. A word written entirely in Cyrillic is not imitating
22
+ # anything; it is a word in Cyrillic. UTS #39 draws the same line and calls
23
+ # it mixed-script detection.
24
+ #
25
+ # So `fold` leaves single-script words alone and folds the mixed ones. That
26
+ # keeps genuine multilingual documentation intact and still catches the
27
+ # attack, which the corpus asserts in both directions.
28
+ module Confusables
29
+ # A run of non-space characters. Folding is decided per word because that
30
+ # is the unit the mixing happens in.
31
+ WORD = /\S+/
32
+
33
+ module_function
34
+
35
+ # Every word that mixes ASCII with imitators, folded. Text with no
36
+ # imitators at all is returned untouched and costs one match.
37
+ def fold(text)
38
+ body = text.to_s
39
+ return body unless body.match?(PATTERN)
40
+
41
+ body.gsub(WORD) { |word| mixed?(word) ? fold_word(word) : word }
42
+ end
43
+
44
+ # Folds regardless of mixing. For a caller that has already decided the
45
+ # text should be Latin, and for measuring what the policy costs.
46
+ def fold_all(text)
47
+ body = text.to_s
48
+ return body unless body.match?(PATTERN)
49
+
50
+ fold_word(body)
51
+ end
52
+
53
+ def confusable?(text)
54
+ text.to_s.match?(PATTERN)
55
+ end
56
+
57
+ # A word imitating ASCII carries both: at least one ASCII letter or digit,
58
+ # and at least one character pretending to be one.
59
+ def mixed?(word)
60
+ word.match?(/[A-Za-z0-9]/) && word.match?(PATTERN)
61
+ end
62
+
63
+ def fold_word(word)
64
+ word.gsub(PATTERN) { |char| MAP.fetch(char, char) }
65
+ end
66
+ end
67
+ end