vangrail 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +655 -43
  3. data/lib/vangrail/actions.rb +10 -3
  4. data/lib/vangrail/assessor.rb +249 -0
  5. data/lib/vangrail/bayes_data.rb +340 -0
  6. data/lib/vangrail/beta.rb +102 -0
  7. data/lib/vangrail/builder.rb +354 -0
  8. data/lib/vangrail/chat.rb +17 -15
  9. data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
  10. data/lib/vangrail/client.rb +27 -18
  11. data/lib/vangrail/colang/ast.rb +29 -3
  12. data/lib/vangrail/colang/interpreter.rb +55 -31
  13. data/lib/vangrail/colang/parser.rb +19 -61
  14. data/lib/vangrail/colang/value_parser.rb +161 -0
  15. data/lib/vangrail/completion.rb +86 -0
  16. data/lib/vangrail/config.rb +35 -15
  17. data/lib/vangrail/conversation.rb +240 -11
  18. data/lib/vangrail/dojo.rb +126 -0
  19. data/lib/vangrail/embeddings.rb +87 -0
  20. data/lib/vangrail/engine.rb +29 -70
  21. data/lib/vangrail/errors.rb +6 -1
  22. data/lib/vangrail/evidence.rb +303 -0
  23. data/lib/vangrail/evidence_data.rb +113 -0
  24. data/lib/vangrail/http.rb +18 -13
  25. data/lib/vangrail/judgement.rb +151 -0
  26. data/lib/vangrail/known_attacks.rb +45 -0
  27. data/lib/vangrail/linear_model.rb +124 -0
  28. data/lib/vangrail/nlp.rb +596 -0
  29. data/lib/vangrail/origin.rb +249 -0
  30. data/lib/vangrail/parsers.rb +5 -5
  31. data/lib/vangrail/profile.rb +114 -0
  32. data/lib/vangrail/prompt.rb +14 -3
  33. data/lib/vangrail/provider.rb +106 -75
  34. data/lib/vangrail/providers/gateway.rb +15 -14
  35. data/lib/vangrail/providers/llmlite.rb +25 -10
  36. data/lib/vangrail/providers.rb +6 -8
  37. data/lib/vangrail/rail.rb +46 -8
  38. data/lib/vangrail/rails/alignment.rb +91 -0
  39. data/lib/vangrail/rails/bayes.rb +115 -0
  40. data/lib/vangrail/rails/budget.rb +2 -2
  41. data/lib/vangrail/rails/canary.rb +2 -2
  42. data/lib/vangrail/rails/colang_flow.rb +9 -1
  43. data/lib/vangrail/rails/escalation.rb +15 -8
  44. data/lib/vangrail/rails/exfiltration.rb +2 -2
  45. data/lib/vangrail/rails/grounding.rb +8 -5
  46. data/lib/vangrail/rails/guard_model.rb +7 -4
  47. data/lib/vangrail/rails/hidden.rb +52 -9
  48. data/lib/vangrail/rails/injected_instructions.rb +29 -9
  49. data/lib/vangrail/rails/jailbreak.rb +2 -6
  50. data/lib/vangrail/rails/known_answer.rb +6 -2
  51. data/lib/vangrail/rails/language.rb +87 -0
  52. data/lib/vangrail/rails/linear.rb +80 -0
  53. data/lib/vangrail/rails/many_shot.rb +2 -6
  54. data/lib/vangrail/rails/markup.rb +3 -3
  55. data/lib/vangrail/rails/missing.rb +1 -5
  56. data/lib/vangrail/rails/obfuscation.rb +81 -13
  57. data/lib/vangrail/rails/paraphrase.rb +189 -0
  58. data/lib/vangrail/rails/pattern.rb +2 -6
  59. data/lib/vangrail/rails/perplexity.rb +100 -0
  60. data/lib/vangrail/rails/personal_data.rb +41 -9
  61. data/lib/vangrail/rails/prompt_leak.rb +132 -0
  62. data/lib/vangrail/rails/remote.rb +5 -1
  63. data/lib/vangrail/rails/secrets.rb +2 -2
  64. data/lib/vangrail/rails/self_check.rb +9 -6
  65. data/lib/vangrail/rails/semantic.rb +132 -0
  66. data/lib/vangrail/rails/similarity.rb +96 -0
  67. data/lib/vangrail/rails/trajectory.rb +10 -5
  68. data/lib/vangrail/result.rb +3 -3
  69. data/lib/vangrail/result_cache.rb +0 -0
  70. data/lib/vangrail/screening.rb +68 -0
  71. data/lib/vangrail/session.rb +365 -0
  72. data/lib/vangrail/spotlight.rb +48 -8
  73. data/lib/vangrail/stream_guard.rb +8 -6
  74. data/lib/vangrail/tools.rb +58 -0
  75. data/lib/vangrail/version.rb +1 -1
  76. data/lib/vangrail.rb +39 -258
  77. metadata +34 -5
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../embeddings'
4
+ require_relative '../known_attacks'
5
+ require_relative '../nlp'
6
+ require_relative '../rail'
7
+
8
+ module Vangrail
9
+ module Rails
10
+ # Compares meaning rather than words, through whatever endpoint is already
11
+ # configured.
12
+ #
13
+ # Rails::Paraphrase reaches exactly as far as the words in NLP::CONCEPTS,
14
+ # and Rails::Similarity exactly as far as the wordings in KnownAttacks. Both
15
+ # limits are the same limit written twice: a synonym nobody listed is a
16
+ # miss. An embedding is the cheap way past it. "Countermand the guidance
17
+ # issued to you" shares no listed word with "ignore all previous
18
+ # instructions" and sits next to it in a vector space.
19
+ #
20
+ # This is the one rail here that is genuinely semantic, and it costs a round
21
+ # trip, so it is opt-in and it runs beside the deterministic rails rather
22
+ # than instead of them. When it cannot run it says so: an endpoint that
23
+ # serves no embedding model, or refuses the call, produces passed with
24
+ # certain? false, never a clean pass.
25
+ #
26
+ # On a loopback proxy it costs no money, keeps the retrieved text on the
27
+ # machine, and adds one local call per document. On a third-party endpoint
28
+ # it is a data-flow decision: every document screened is a document sent.
29
+ # That is why nothing here picks an endpoint on its own.
30
+ #
31
+ # The threshold is the part that cannot ship measured. Cosine scores are a
32
+ # property of the embedding model, not of this gem, so 0.75 is a starting
33
+ # point rather than a finding: run script/embedding_probe.rb against the
34
+ # endpoint you actually use, read the gap between its benign and attack
35
+ # distributions, and set the number from that. A threshold nobody measured
36
+ # on the model in use is a number, not a defence.
37
+ class Semantic < Rail
38
+ THRESHOLD = 0.75
39
+
40
+ # Clauses shorter than this are not compared. A four-word fragment
41
+ # embeds to something close to everything, and the score it produces is
42
+ # noise that only ever costs a false positive.
43
+ FLOOR = 24
44
+
45
+ # An upper bound on the work one document can ask for. A long page has
46
+ # hundreds of clauses, and embedding all of them turns one round trip into
47
+ # a payload nobody budgeted for. The longest clauses are kept, because an
48
+ # injected instruction is a sentence rather than a fragment.
49
+ #
50
+ # A page that exceeds it is not fully checked, and the result says so with
51
+ # certain? false rather than with a footnote on a clean pass. That is the
52
+ # same rule the rest of this gem follows: a partial check is not a check.
53
+ MAX_CLAUSES = 64
54
+
55
+ attr_reader :embeddings, :seeds, :threshold
56
+
57
+ def initialize(embeddings:, seeds: KnownAttacks::ALL, threshold: THRESHOLD, floor: FLOOR,
58
+ max_clauses: MAX_CLAUSES, name: 'semantic', sides: %i[input context])
59
+ super(name: name, sides: sides)
60
+ @embeddings = embeddings
61
+ @seeds = Array(seeds)
62
+ @threshold = threshold
63
+ @floor = floor
64
+ @max_clauses = max_clauses
65
+ end
66
+
67
+ def offline?
68
+ false
69
+ end
70
+
71
+ # Not memoizable across models or thresholds, and the text alone is not
72
+ # the question being asked.
73
+ def cache_key(text, _context)
74
+ "#{embeddings.model}\n#{threshold}\n#{text}"
75
+ end
76
+
77
+ def decide(text, _context)
78
+ clauses, dropped = candidates(text)
79
+ return pass if clauses.empty?
80
+
81
+ score, seed, clause = nearest(clauses)
82
+ if score < threshold
83
+ return dropped.zero? ? pass : unchecked(cut_reason(dropped))
84
+ end
85
+
86
+ block(categories: ['semantic_match'],
87
+ reason: format('reads as a known attack (%<score>.2f against "%<seed>s"): %<clause>s',
88
+ score: score, seed: seed, clause: clause[0, 80]))
89
+ rescue Error => e
90
+ unchecked("semantic check did not run: #{e.message}")
91
+ end
92
+
93
+ # The closest seed, its score, and the clause that matched, for a caller
94
+ # that wants the number rather than the verdict. Raises what the transport
95
+ # raises: a probe script wants the error, a rail wants a Result.
96
+ def nearest(clauses)
97
+ vectors = embeddings.embed(clauses)
98
+ best = [-1.0, nil, nil]
99
+ vectors.each_with_index do |vector, i|
100
+ seed_vectors.each_with_index do |seed_vector, j|
101
+ score = Embeddings.cosine(vector, seed_vector)
102
+ best = [score, seeds[j], clauses[i]] if score > best.first
103
+ end
104
+ end
105
+ best
106
+ end
107
+
108
+ # Embedded once per rail, on first use rather than at construction: a rail
109
+ # that is never reached should never have called the endpoint, and an
110
+ # engine built with no network available must still build.
111
+ def seed_vectors
112
+ @seed_vectors ||= embeddings.embed(seeds)
113
+ end
114
+
115
+ private
116
+
117
+ def candidates(text)
118
+ clauses = NLP.clauses(text).select { |clause| clause.length >= @floor }
119
+ return [clauses, 0] if clauses.size <= @max_clauses
120
+
121
+ kept = clauses.sort_by { |clause| -clause.length }.first(@max_clauses)
122
+ [kept, clauses.size - kept.size]
123
+ end
124
+
125
+ # A rail that looked at part of a page has to say which part it skipped,
126
+ # or the pass it returns claims more than it checked.
127
+ def cut_reason(dropped)
128
+ "checked the #{@max_clauses} longest clauses; #{dropped} shorter ones were not embedded"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../known_attacks'
4
+ require_relative '../nlp'
5
+ require_relative '../rail'
6
+
7
+ module Vangrail
8
+ module Rails
9
+ # Catches the known attack that was edited rather than reworded.
10
+ #
11
+ # A published jailbreak spreads by being pasted, and what arrives is a near
12
+ # copy: a typo, two words inserted, different capitals, a word inflected
13
+ # differently, an exclamation mark added. A regexp misses all of those
14
+ # unless somebody widens it for each one, and every widening is spent from
15
+ # the same false-positive budget. Near-duplicate detection is the standard
16
+ # answer, and the standard measure is containment over character n-grams:
17
+ # how much of the known sentence is present in the text, rather than how
18
+ # similar the two are overall.
19
+ #
20
+ # Containment rather than Jaccard, because the case is a sentence inside a
21
+ # page. A wiki page with one pasted jailbreak in it is 99% ordinary prose,
22
+ # so overlap over union is near zero however exact the copy, and the
23
+ # measure that answers "is this in there" is the one that divides by the
24
+ # seed.
25
+ #
26
+ # Clause by clause rather than page by page, and that is not a detail. A
27
+ # long page accidentally contains most of the four-character n-grams of any
28
+ # short English sentence: measured against the benign corpus in this repo,
29
+ # a page of ordinary documentation scores 0.94 against a seed it does not
30
+ # contain, and the same corpus split into clauses scores 0.67. Containment
31
+ # saturates with length, so the comparison has to be against a span the
32
+ # size of the thing being looked for.
33
+ #
34
+ # What this does not do is judge. A clause either reproduces a sentence
35
+ # somebody published or it does not; there is no scoring of intent, and a
36
+ # novel attack in nobody's corpus scores zero here by construction.
37
+ class Similarity < Rail
38
+ # Measured on the corpora in this repo: ordinary documentation tops out
39
+ # at 0.67 against the nearest seed, and edited copies of the seeds bottom
40
+ # out at 0.83. The gap is where the threshold goes, and 0.75 is the
41
+ # middle of it rather than a round number picked first.
42
+ THRESHOLD = 0.75
43
+
44
+ # Four characters: long enough that a shingle is a fragment of a word
45
+ # rather than a letter pair, short enough that a typo costs four shingles
46
+ # instead of a whole token.
47
+ SHINGLE = 4
48
+
49
+ attr_reader :threshold, :seeds
50
+
51
+ def initialize(seeds: KnownAttacks::ALL, threshold: THRESHOLD, shingle: SHINGLE,
52
+ name: 'similarity', sides: %i[input context])
53
+ super(name: name, sides: sides)
54
+ @threshold = threshold
55
+ @shingle = shingle
56
+ @seeds = seeds.map { |seed| [seed, NLP.shingles(seed, size: shingle)] }.freeze
57
+ # Every n-gram any seed contains, and the smallest number of them a
58
+ # clause needs before any seed can possibly clear the threshold. One
59
+ # intersection against this decides whether the clause is worth
60
+ # comparing seed by seed, and on ordinary prose it decides no. The
61
+ # bound is exact rather than heuristic: a clause sharing fewer grams
62
+ # with the union than the shortest seed needs cannot contain any seed.
63
+ @union = @seeds.flat_map { |(_, shingles)| shingles.to_a }.to_set.freeze
64
+ @floor = (threshold * @seeds.map { |(_, shingles)| shingles.size }.min).ceil
65
+ end
66
+
67
+ def cache_key(text, _context)
68
+ "#{threshold}\n#{text}"
69
+ end
70
+
71
+ def decide(text, _context)
72
+ score, seed = nearest(text)
73
+ return pass if score < threshold
74
+
75
+ block(categories: ['known_attack'],
76
+ reason: format('near copy of a known attack (%<score>.2f): %<seed>s', score: score, seed: seed))
77
+ end
78
+
79
+ # The closest seed and how much of it is present, for a caller that wants
80
+ # the number rather than the verdict.
81
+ def nearest(text)
82
+ best = [0.0, nil]
83
+ NLP.clauses(text).each do |clause|
84
+ present = NLP.shingles(clause, size: @shingle)
85
+ next if (present & @union).size < @floor
86
+
87
+ seeds.each do |(seed, shingles)|
88
+ score = NLP.containment(shingles, present)
89
+ best = [score, seed] if score > best.first
90
+ end
91
+ end
92
+ best
93
+ end
94
+ end
95
+ end
96
+ end
@@ -55,13 +55,19 @@ module Vangrail
55
55
  end
56
56
  end
57
57
 
58
+ def offline?
59
+ false
60
+ end
61
+
58
62
  # Never memoizable: the same message means different things depending on
59
63
  # what it follows.
60
64
  def cache_key(_text, _context)
61
65
  nil
62
66
  end
63
67
 
64
- def call(text, context)
68
+ def decide(text, context)
69
+ return unchecked('no history was provided, so the sequence was not judged') unless context.key?(:history)
70
+
65
71
  turns = Array(context[:history]).last(window)
66
72
  return pass if turns.size < min_turns
67
73
 
@@ -82,13 +88,12 @@ module Vangrail
82
88
  def judge(text, turns)
83
89
  answer = chat.ask([
84
90
  { 'role' => 'system', 'content' => policy },
85
- { 'role' => 'user', 'content' => Policies.trajectory_prompt(turns, text) }
91
+ { 'role' => 'user', 'content' => Policies.trajectory_prompt(turns, text) },
86
92
  ])
87
93
  parsed = Parsers.policy(answer.text)
88
94
  unless parsed[:decided]
89
- return Result.new(status: :passed, rail: name, certain: false, model: model,
90
- latency_ms: answer.latency_ms, raw: answer.raw,
91
- reason: "unparsed judge response: #{parsed[:reason]}")
95
+ return unchecked("unparsed judge response: #{parsed[:reason]}",
96
+ model: model, latency_ms: answer.latency_ms, raw: answer.raw)
92
97
  end
93
98
 
94
99
  return pass(model: model, latency_ms: answer.latency_ms, raw: answer.raw) unless parsed[:violated]
@@ -52,8 +52,8 @@ module Vangrail
52
52
  end
53
53
 
54
54
  # No rail ran. Allowed, and explicitly not vouched for.
55
- def self.unchecked(rail:, reason:)
56
- new(status: :passed, rail: rail, certain: false, reason: reason)
55
+ def self.unchecked(rail:, reason:, **kwargs)
56
+ new(status: :passed, rail: rail, certain: false, reason: reason, **kwargs)
57
57
  end
58
58
 
59
59
  def passed?
@@ -99,7 +99,7 @@ module Vangrail
99
99
  'reason' => reason,
100
100
  'categories' => (categories unless categories.empty?),
101
101
  'model' => model,
102
- 'latency_ms' => latency_ms
102
+ 'latency_ms' => latency_ms,
103
103
  }.compact
104
104
  end
105
105
 
Binary file
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'origin'
4
+
5
+ module Vangrail
6
+ # What Engine#screen returns. `certain` means what it means on a Result:
7
+ # false says a rail did not reach a decision about some document, so
8
+ # "nothing was rejected" is not evidence that nothing was wrong.
9
+ Screening = Struct.new(:kept, :rejected, :certain, :reason, keyword_init: true) do
10
+ def certain?
11
+ certain
12
+ end
13
+
14
+ def rejected?
15
+ !rejected.empty?
16
+ end
17
+
18
+ def cells
19
+ kept.map { |document| Cell.data(Cell.text_of(document)) }
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ 'kept' => kept.size,
25
+ 'rejected' => rejected.map { |r| r[:result].to_h },
26
+ 'certain' => certain?,
27
+ 'reason' => reason,
28
+ }.compact
29
+ end
30
+
31
+ # Screens a set of retrieved documents and reports what survived.
32
+ #
33
+ # A document that fails is dropped rather than failing the whole turn. One
34
+ # poisoned wiki page should cost a reader that page, not their answer, and
35
+ # an application that refuses outright teaches its readers that the
36
+ # guardrail is the problem.
37
+ def self.run(engine, documents, **context)
38
+ kept = []
39
+ rejected = []
40
+ uncertain = nil
41
+
42
+ Array(documents).each_with_index do |document, index|
43
+ result = engine.check_context(text_of(document), **context, document: document, index: index)
44
+ uncertain ||= result unless result.certain?
45
+ if result.blocked?
46
+ rejected << { document: document, result: result }
47
+ else
48
+ kept << (result.modified? ? replace_text(document, result.content) : document)
49
+ end
50
+ end
51
+
52
+ new(kept: kept, rejected: rejected, certain: uncertain.nil?, reason: uncertain&.reason)
53
+ end
54
+
55
+ def self.text_of(document)
56
+ Cell.text_of(document)
57
+ end
58
+
59
+ # A context rail may rewrite a document rather than reject it, so the
60
+ # replacement has to go back into the shape the caller passed in.
61
+ def self.replace_text(document, content)
62
+ return content.to_s unless document.is_a?(Hash)
63
+
64
+ key = document.key?('text') ? 'text' : :text
65
+ document.merge(key => content.to_s)
66
+ end
67
+ end
68
+ end