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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +473 -0
- data/lib/vangrail/actions.rb +61 -0
- data/lib/vangrail/chat.rb +88 -0
- data/lib/vangrail/client/completion.rb +122 -0
- data/lib/vangrail/client.rb +219 -0
- data/lib/vangrail/colang/ast.rb +53 -0
- data/lib/vangrail/colang/interpreter.rb +131 -0
- data/lib/vangrail/colang/library.rb +53 -0
- data/lib/vangrail/colang/parser.rb +222 -0
- data/lib/vangrail/config.rb +270 -0
- data/lib/vangrail/confusables.rb +67 -0
- data/lib/vangrail/confusables_data.rb +1673 -0
- data/lib/vangrail/conversation.rb +105 -0
- data/lib/vangrail/engine.rb +240 -0
- data/lib/vangrail/errors.rb +48 -0
- data/lib/vangrail/http.rb +109 -0
- data/lib/vangrail/parsers.rb +181 -0
- data/lib/vangrail/policies.rb +202 -0
- data/lib/vangrail/prompt.rb +88 -0
- data/lib/vangrail/provider.rb +191 -0
- data/lib/vangrail/providers/gateway.rb +131 -0
- data/lib/vangrail/providers/llmlite.rb +71 -0
- data/lib/vangrail/providers.rb +72 -0
- data/lib/vangrail/rail.rb +93 -0
- data/lib/vangrail/rails/budget.rb +63 -0
- data/lib/vangrail/rails/canary.rb +76 -0
- data/lib/vangrail/rails/colang_flow.rb +40 -0
- data/lib/vangrail/rails/escalation.rb +178 -0
- data/lib/vangrail/rails/exfiltration.rb +167 -0
- data/lib/vangrail/rails/grounding.rb +64 -0
- data/lib/vangrail/rails/guard_model.rb +96 -0
- data/lib/vangrail/rails/hidden.rb +105 -0
- data/lib/vangrail/rails/injected_instructions.rb +86 -0
- data/lib/vangrail/rails/jailbreak.rb +114 -0
- data/lib/vangrail/rails/known_answer.rb +118 -0
- data/lib/vangrail/rails/many_shot.rb +80 -0
- data/lib/vangrail/rails/markup.rb +77 -0
- data/lib/vangrail/rails/missing.rb +38 -0
- data/lib/vangrail/rails/obfuscation.rb +186 -0
- data/lib/vangrail/rails/pattern.rb +57 -0
- data/lib/vangrail/rails/personal_data.rb +152 -0
- data/lib/vangrail/rails/remote.rb +40 -0
- data/lib/vangrail/rails/secrets.rb +77 -0
- data/lib/vangrail/rails/self_check.rb +81 -0
- data/lib/vangrail/rails/trajectory.rb +101 -0
- data/lib/vangrail/result.rb +114 -0
- data/lib/vangrail/result_cache.rb +0 -0
- data/lib/vangrail/spotlight.rb +157 -0
- data/lib/vangrail/stream_guard.rb +163 -0
- data/lib/vangrail/version.rb +5 -0
- data/lib/vangrail.rb +354 -0
- 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
|