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.
- checksums.yaml +4 -4
- data/README.md +655 -43
- data/lib/vangrail/actions.rb +10 -3
- data/lib/vangrail/assessor.rb +249 -0
- data/lib/vangrail/bayes_data.rb +340 -0
- data/lib/vangrail/beta.rb +102 -0
- data/lib/vangrail/builder.rb +354 -0
- data/lib/vangrail/chat.rb +17 -15
- data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
- data/lib/vangrail/client.rb +27 -18
- data/lib/vangrail/colang/ast.rb +29 -3
- data/lib/vangrail/colang/interpreter.rb +55 -31
- data/lib/vangrail/colang/parser.rb +19 -61
- data/lib/vangrail/colang/value_parser.rb +161 -0
- data/lib/vangrail/completion.rb +86 -0
- data/lib/vangrail/config.rb +35 -15
- data/lib/vangrail/conversation.rb +240 -11
- data/lib/vangrail/dojo.rb +126 -0
- data/lib/vangrail/embeddings.rb +87 -0
- data/lib/vangrail/engine.rb +29 -70
- data/lib/vangrail/errors.rb +6 -1
- data/lib/vangrail/evidence.rb +303 -0
- data/lib/vangrail/evidence_data.rb +113 -0
- data/lib/vangrail/http.rb +18 -13
- data/lib/vangrail/judgement.rb +151 -0
- data/lib/vangrail/known_attacks.rb +45 -0
- data/lib/vangrail/linear_model.rb +124 -0
- data/lib/vangrail/nlp.rb +596 -0
- data/lib/vangrail/origin.rb +249 -0
- data/lib/vangrail/parsers.rb +5 -5
- data/lib/vangrail/profile.rb +114 -0
- data/lib/vangrail/prompt.rb +14 -3
- data/lib/vangrail/provider.rb +106 -75
- data/lib/vangrail/providers/gateway.rb +15 -14
- data/lib/vangrail/providers/llmlite.rb +25 -10
- data/lib/vangrail/providers.rb +6 -8
- data/lib/vangrail/rail.rb +46 -8
- data/lib/vangrail/rails/alignment.rb +91 -0
- data/lib/vangrail/rails/bayes.rb +115 -0
- data/lib/vangrail/rails/budget.rb +2 -2
- data/lib/vangrail/rails/canary.rb +2 -2
- data/lib/vangrail/rails/colang_flow.rb +9 -1
- data/lib/vangrail/rails/escalation.rb +15 -8
- data/lib/vangrail/rails/exfiltration.rb +2 -2
- data/lib/vangrail/rails/grounding.rb +8 -5
- data/lib/vangrail/rails/guard_model.rb +7 -4
- data/lib/vangrail/rails/hidden.rb +52 -9
- data/lib/vangrail/rails/injected_instructions.rb +29 -9
- data/lib/vangrail/rails/jailbreak.rb +2 -6
- data/lib/vangrail/rails/known_answer.rb +6 -2
- data/lib/vangrail/rails/language.rb +87 -0
- data/lib/vangrail/rails/linear.rb +80 -0
- data/lib/vangrail/rails/many_shot.rb +2 -6
- data/lib/vangrail/rails/markup.rb +3 -3
- data/lib/vangrail/rails/missing.rb +1 -5
- data/lib/vangrail/rails/obfuscation.rb +81 -13
- data/lib/vangrail/rails/paraphrase.rb +189 -0
- data/lib/vangrail/rails/pattern.rb +2 -6
- data/lib/vangrail/rails/perplexity.rb +100 -0
- data/lib/vangrail/rails/personal_data.rb +41 -9
- data/lib/vangrail/rails/prompt_leak.rb +132 -0
- data/lib/vangrail/rails/remote.rb +5 -1
- data/lib/vangrail/rails/secrets.rb +2 -2
- data/lib/vangrail/rails/self_check.rb +9 -6
- data/lib/vangrail/rails/semantic.rb +132 -0
- data/lib/vangrail/rails/similarity.rb +96 -0
- data/lib/vangrail/rails/trajectory.rb +10 -5
- data/lib/vangrail/result.rb +3 -3
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/screening.rb +68 -0
- data/lib/vangrail/session.rb +365 -0
- data/lib/vangrail/spotlight.rb +48 -8
- data/lib/vangrail/stream_guard.rb +8 -6
- data/lib/vangrail/tools.rb +58 -0
- data/lib/vangrail/version.rb +1 -1
- data/lib/vangrail.rb +39 -258
- 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
|
|
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
|
|
90
|
-
|
|
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]
|
data/lib/vangrail/result.rb
CHANGED
|
@@ -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
|