guardrails-ruby 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/CLAUDE.md +507 -0
- data/Gemfile +2 -0
- data/LICENSE +21 -0
- data/README.md +243 -0
- data/Rakefile +9 -0
- data/examples/basic.rb +64 -0
- data/examples/custom_check.rb +103 -0
- data/examples/rails_controller.rb +73 -0
- data/guardrails-ruby.gemspec +30 -0
- data/lib/guardrails_ruby/check.rb +64 -0
- data/lib/guardrails_ruby/checks/competitor_mention.rb +36 -0
- data/lib/guardrails_ruby/checks/encoding.rb +33 -0
- data/lib/guardrails_ruby/checks/format.rb +35 -0
- data/lib/guardrails_ruby/checks/hallucinated_emails.rb +30 -0
- data/lib/guardrails_ruby/checks/hallucinated_urls.rb +38 -0
- data/lib/guardrails_ruby/checks/keyword_filter.rb +33 -0
- data/lib/guardrails_ruby/checks/max_length.rb +30 -0
- data/lib/guardrails_ruby/checks/pii.rb +54 -0
- data/lib/guardrails_ruby/checks/prompt_injection.rb +36 -0
- data/lib/guardrails_ruby/checks/relevance.rb +43 -0
- data/lib/guardrails_ruby/checks/topic.rb +25 -0
- data/lib/guardrails_ruby/checks/toxic_language.rb +28 -0
- data/lib/guardrails_ruby/configuration.rb +15 -0
- data/lib/guardrails_ruby/guard.rb +129 -0
- data/lib/guardrails_ruby/middleware.rb +30 -0
- data/lib/guardrails_ruby/rails/controller.rb +57 -0
- data/lib/guardrails_ruby/rails/railtie.rb +20 -0
- data/lib/guardrails_ruby/redactors/keyword_redactor.rb +33 -0
- data/lib/guardrails_ruby/redactors/pii_redactor.rb +59 -0
- data/lib/guardrails_ruby/result.rb +53 -0
- data/lib/guardrails_ruby/version.rb +5 -0
- data/lib/guardrails_ruby/violation.rb +41 -0
- data/lib/guardrails_ruby.rb +38 -0
- metadata +115 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class HallucinatedEmails < Check
|
|
6
|
+
check_name :hallucinated_emails
|
|
7
|
+
direction :output
|
|
8
|
+
|
|
9
|
+
EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/
|
|
10
|
+
|
|
11
|
+
def call(text, context: {})
|
|
12
|
+
emails = text.scan(EMAIL_PATTERN)
|
|
13
|
+
return pass! if emails.empty?
|
|
14
|
+
|
|
15
|
+
source_context = context[:source_context] || ""
|
|
16
|
+
source_emails = source_context.scan(EMAIL_PATTERN).map(&:downcase)
|
|
17
|
+
|
|
18
|
+
hallucinated = emails.reject { |e| source_emails.include?(e.downcase) }
|
|
19
|
+
|
|
20
|
+
if hallucinated.any?
|
|
21
|
+
fail! "Potentially hallucinated emails: #{hallucinated.join(', ')}",
|
|
22
|
+
action: @options.fetch(:action, :warn),
|
|
23
|
+
matches: hallucinated
|
|
24
|
+
else
|
|
25
|
+
pass!
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class HallucinatedURLs < Check
|
|
6
|
+
check_name :hallucinated_urls
|
|
7
|
+
direction :output
|
|
8
|
+
|
|
9
|
+
URL_PATTERN = %r{https?://[^\s<>"{}|\\^`\[\]]+}
|
|
10
|
+
|
|
11
|
+
def call(text, context: {})
|
|
12
|
+
urls = text.scan(URL_PATTERN)
|
|
13
|
+
return pass! if urls.empty?
|
|
14
|
+
|
|
15
|
+
source_context = context[:source_context] || ""
|
|
16
|
+
source_urls = source_context.scan(URL_PATTERN)
|
|
17
|
+
|
|
18
|
+
hallucinated = urls.reject do |url|
|
|
19
|
+
source_urls.any? { |s| normalize(url).start_with?(normalize(s)) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if hallucinated.any?
|
|
23
|
+
fail! "Potentially hallucinated URLs: #{hallucinated.join(', ')}",
|
|
24
|
+
action: @options.fetch(:action, :warn),
|
|
25
|
+
matches: hallucinated
|
|
26
|
+
else
|
|
27
|
+
pass!
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def normalize(url)
|
|
34
|
+
url.downcase.chomp("/")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class KeywordFilter < Check
|
|
6
|
+
check_name :keyword_filter
|
|
7
|
+
direction :both
|
|
8
|
+
|
|
9
|
+
def call(text, context: {})
|
|
10
|
+
blocklist = @options.fetch(:blocklist, [])
|
|
11
|
+
allowlist = @options.fetch(:allowlist, [])
|
|
12
|
+
|
|
13
|
+
text_lower = text.downcase
|
|
14
|
+
|
|
15
|
+
if blocklist.any?
|
|
16
|
+
found = blocklist.select { |kw| text_lower.include?(kw.downcase) }
|
|
17
|
+
if found.any?
|
|
18
|
+
return fail!("Blocked keywords found: #{found.join(', ')}", matches: found)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if allowlist.any?
|
|
23
|
+
has_allowed = allowlist.any? { |kw| text_lower.include?(kw.downcase) }
|
|
24
|
+
unless has_allowed
|
|
25
|
+
return fail!("No allowed keywords found. Expected one of: #{allowlist.join(', ')}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
pass!
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class MaxLength < Check
|
|
6
|
+
check_name :max_length
|
|
7
|
+
direction :input
|
|
8
|
+
|
|
9
|
+
def call(text, context: {})
|
|
10
|
+
max_chars = @options[:chars]
|
|
11
|
+
max_tokens = @options[:tokens]
|
|
12
|
+
|
|
13
|
+
if max_chars && text.length > max_chars
|
|
14
|
+
fail! "Input exceeds maximum length of #{max_chars} characters (got #{text.length})"
|
|
15
|
+
elsif max_tokens && estimate_tokens(text) > max_tokens
|
|
16
|
+
fail! "Input exceeds maximum length of #{max_tokens} tokens (estimated #{estimate_tokens(text)})"
|
|
17
|
+
else
|
|
18
|
+
pass!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Simple token estimation (~4 chars per token)
|
|
25
|
+
def estimate_tokens(text)
|
|
26
|
+
(text.length / 4.0).ceil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class PII < Check
|
|
6
|
+
check_name :pii
|
|
7
|
+
direction :both
|
|
8
|
+
|
|
9
|
+
PATTERNS = {
|
|
10
|
+
ssn: /\b\d{3}-\d{2}-\d{4}\b/,
|
|
11
|
+
credit_card: /\b(?:\d{4}[- ]?){3}\d{4}\b/,
|
|
12
|
+
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/,
|
|
13
|
+
phone_us: /\b(?:\+?1[-.]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/,
|
|
14
|
+
ip_address: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/,
|
|
15
|
+
date_of_birth: /\b(?:DOB|date of birth|born)[:\s]*\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}\b/i
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
REDACT_MAP = {
|
|
19
|
+
ssn: "[SSN REDACTED]",
|
|
20
|
+
credit_card: "[CC REDACTED]",
|
|
21
|
+
email: "[EMAIL REDACTED]",
|
|
22
|
+
phone_us: "[PHONE REDACTED]",
|
|
23
|
+
ip_address: "[IP REDACTED]",
|
|
24
|
+
date_of_birth: "[DOB REDACTED]"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def call(text, context: {})
|
|
28
|
+
found = {}
|
|
29
|
+
PATTERNS.each do |type, pattern|
|
|
30
|
+
matches = text.scan(pattern)
|
|
31
|
+
found[type] = matches if matches.any?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if found.any?
|
|
35
|
+
fail! "PII detected: #{found.keys.join(', ')}",
|
|
36
|
+
matches: found,
|
|
37
|
+
sanitized: redact(text, found)
|
|
38
|
+
else
|
|
39
|
+
pass!
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def redact(text, found)
|
|
46
|
+
result = text.dup
|
|
47
|
+
PATTERNS.each do |type, pattern|
|
|
48
|
+
result.gsub!(pattern, REDACT_MAP[type]) if found.key?(type)
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class PromptInjection < Check
|
|
6
|
+
check_name :prompt_injection
|
|
7
|
+
direction :input
|
|
8
|
+
|
|
9
|
+
INJECTION_PATTERNS = [
|
|
10
|
+
/ignore\s+(all\s+)?previous\s+instructions/i,
|
|
11
|
+
/ignore\s+(all\s+)?above/i,
|
|
12
|
+
/disregard\s+(all\s+)?previous/i,
|
|
13
|
+
/you\s+are\s+now\s+(a|an)\s+/i,
|
|
14
|
+
/pretend\s+(you('re|\s+are)\s+|to\s+be\s+)/i,
|
|
15
|
+
/act\s+as\s+(a|an|if)\s+/i,
|
|
16
|
+
/new\s+instructions?[:\s]/i,
|
|
17
|
+
/system\s*prompt[:\s]/i,
|
|
18
|
+
/\[\s*system\s*\]/i,
|
|
19
|
+
/<\s*system\s*>/i,
|
|
20
|
+
/```\s*(system|instruction)/i,
|
|
21
|
+
/STOP\.?\s*(forget|ignore|disregard)/i
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def call(text, context: {})
|
|
25
|
+
matched = INJECTION_PATTERNS.select { |p| text.match?(p) }
|
|
26
|
+
|
|
27
|
+
if matched.any?
|
|
28
|
+
fail! "Prompt injection detected",
|
|
29
|
+
matches: matched.map(&:source)
|
|
30
|
+
else
|
|
31
|
+
pass!
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class Relevance < Check
|
|
6
|
+
check_name :relevance
|
|
7
|
+
direction :output
|
|
8
|
+
|
|
9
|
+
# This check requires LLM-based evaluation for real accuracy.
|
|
10
|
+
# For now, a simple heuristic: check if any words from the input appear in the output.
|
|
11
|
+
def call(text, context: {})
|
|
12
|
+
input_text = context[:input]
|
|
13
|
+
return pass! unless input_text
|
|
14
|
+
|
|
15
|
+
input_words = significant_words(input_text)
|
|
16
|
+
return pass! if input_words.empty?
|
|
17
|
+
|
|
18
|
+
output_lower = text.downcase
|
|
19
|
+
overlap = input_words.count { |w| output_lower.include?(w) }
|
|
20
|
+
ratio = overlap.to_f / input_words.size
|
|
21
|
+
|
|
22
|
+
if ratio < 0.1 && text.length > 20
|
|
23
|
+
fail! "Output may not be relevant to the input (low keyword overlap)",
|
|
24
|
+
action: @options.fetch(:action, :warn)
|
|
25
|
+
else
|
|
26
|
+
pass!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
STOP_WORDS = %w[the a an is are was were be been being have has had do does did
|
|
33
|
+
will would shall should may might can could of in to for on with
|
|
34
|
+
at by from it its this that these those i me my we our you your
|
|
35
|
+
he she they them his her and or but not no if so as how what when
|
|
36
|
+
where which who whom why].freeze
|
|
37
|
+
|
|
38
|
+
def significant_words(text)
|
|
39
|
+
text.downcase.scan(/\b\w+\b/).reject { |w| STOP_WORDS.include?(w) || w.length < 3 }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class Topic < Check
|
|
6
|
+
check_name :topic
|
|
7
|
+
direction :input
|
|
8
|
+
|
|
9
|
+
def call(text, context: {})
|
|
10
|
+
allowed = @options.fetch(:allowed, [])
|
|
11
|
+
return pass! if allowed.empty?
|
|
12
|
+
|
|
13
|
+
# Simple keyword-based topic matching
|
|
14
|
+
text_lower = text.downcase
|
|
15
|
+
on_topic = allowed.any? { |topic| text_lower.include?(topic.to_s.downcase) }
|
|
16
|
+
|
|
17
|
+
if on_topic
|
|
18
|
+
pass!
|
|
19
|
+
else
|
|
20
|
+
fail! "Input does not match allowed topics: #{allowed.join(', ')}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Checks
|
|
5
|
+
class ToxicLanguage < Check
|
|
6
|
+
check_name :toxic_language
|
|
7
|
+
direction :both
|
|
8
|
+
|
|
9
|
+
# Basic keyword-based detection; LLM-based detection can be added later
|
|
10
|
+
TOXIC_PATTERNS = [
|
|
11
|
+
/\b(kill|murder|attack)\s+(you|him|her|them)\b/i,
|
|
12
|
+
/\b(threat(en)?|bomb|terroris[mt])\b/i,
|
|
13
|
+
/\bi\s+(will|am going to)\s+(hurt|harm|destroy)\b/i
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def call(text, context: {})
|
|
17
|
+
matched = TOXIC_PATTERNS.select { |p| text.match?(p) }
|
|
18
|
+
|
|
19
|
+
if matched.any?
|
|
20
|
+
fail! "Toxic language detected",
|
|
21
|
+
matches: matched.map(&:source)
|
|
22
|
+
else
|
|
23
|
+
pass!
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_input_checks, :default_output_checks,
|
|
6
|
+
:on_violation, :judge_llm
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@default_input_checks = []
|
|
10
|
+
@default_output_checks = []
|
|
11
|
+
@on_violation = nil
|
|
12
|
+
@judge_llm = nil
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
class Blocked < StandardError; end
|
|
5
|
+
|
|
6
|
+
class Guard
|
|
7
|
+
def initialize(&block)
|
|
8
|
+
@input_checks = []
|
|
9
|
+
@output_checks = []
|
|
10
|
+
instance_eval(&block) if block
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# DSL: define input checks
|
|
14
|
+
def input(&block)
|
|
15
|
+
@current_checks = @input_checks
|
|
16
|
+
instance_eval(&block)
|
|
17
|
+
@current_checks = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# DSL: define output checks
|
|
21
|
+
def output(&block)
|
|
22
|
+
@current_checks = @output_checks
|
|
23
|
+
instance_eval(&block)
|
|
24
|
+
@current_checks = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# DSL: register a check by name with options
|
|
28
|
+
def check(name, **options)
|
|
29
|
+
check_class = Check.lookup(name)
|
|
30
|
+
raise ArgumentError, "Unknown check: #{name.inspect}" unless check_class
|
|
31
|
+
|
|
32
|
+
target = @current_checks
|
|
33
|
+
raise "check must be called inside an input or output block" unless target
|
|
34
|
+
|
|
35
|
+
target << check_class.new(**options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Run all input checks in order, return merged Result
|
|
39
|
+
def check_input(text)
|
|
40
|
+
run_checks(@input_checks, text)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Run all output checks in order, return merged Result
|
|
44
|
+
def check_output(input: nil, output:, context: {})
|
|
45
|
+
ctx = context.merge(input: input)
|
|
46
|
+
run_checks(@output_checks, output, context: ctx)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Wrap an LLM call with input + output guards
|
|
50
|
+
def call(user_input, context: {})
|
|
51
|
+
input_result = check_input(user_input)
|
|
52
|
+
handle_violations(input_result)
|
|
53
|
+
|
|
54
|
+
sanitized_input = input_result.sanitized || user_input
|
|
55
|
+
|
|
56
|
+
raw_output = yield(sanitized_input)
|
|
57
|
+
|
|
58
|
+
output_result = check_output(input: sanitized_input, output: raw_output, context: context)
|
|
59
|
+
handle_violations(output_result)
|
|
60
|
+
|
|
61
|
+
output_result.sanitized || raw_output
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def run_checks(checks, text, context: {})
|
|
67
|
+
combined = Result.new(original_text: text)
|
|
68
|
+
current_text = text
|
|
69
|
+
|
|
70
|
+
checks.each do |chk|
|
|
71
|
+
result = chk.call(current_text, context: context)
|
|
72
|
+
result = Result.new(original_text: current_text, violations: result.violations)
|
|
73
|
+
|
|
74
|
+
# Apply redaction so subsequent checks see sanitized text
|
|
75
|
+
if result.sanitized && result.sanitized != current_text
|
|
76
|
+
current_text = result.sanitized
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
combined = combined.merge(result)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Ensure final result has the latest sanitized text
|
|
83
|
+
Result.new(
|
|
84
|
+
original_text: text,
|
|
85
|
+
violations: combined.violations
|
|
86
|
+
).tap do |final|
|
|
87
|
+
# Store the running sanitized text by adding a synthetic redaction if text changed
|
|
88
|
+
if current_text != text && !combined.violations.any? { |v| v.action == :redact && v.sanitized }
|
|
89
|
+
# Text was modified through redactions; the sanitized method will find it via violations
|
|
90
|
+
end
|
|
91
|
+
# We need the final sanitized text accessible; use a simple approach:
|
|
92
|
+
# Re-build with a result that tracks the current text
|
|
93
|
+
return FinalResult.new(original_text: text, violations: combined.violations, final_text: current_text)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_violations(result)
|
|
98
|
+
result.violations.each do |violation|
|
|
99
|
+
# Notify global callback
|
|
100
|
+
if GuardrailsRuby.configuration.on_violation
|
|
101
|
+
GuardrailsRuby.configuration.on_violation.call(violation)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
case violation.action
|
|
105
|
+
when :block
|
|
106
|
+
raise Blocked, violation.detail
|
|
107
|
+
when :warn
|
|
108
|
+
warn "[GuardrailsRuby WARN] #{violation}"
|
|
109
|
+
when :log
|
|
110
|
+
# silently record - already in violations
|
|
111
|
+
when :redact
|
|
112
|
+
# handled via sanitized text
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Internal result subclass that tracks the final sanitized text through multiple checks
|
|
119
|
+
class FinalResult < Result
|
|
120
|
+
def initialize(original_text: nil, violations: [], final_text: nil)
|
|
121
|
+
super(original_text: original_text, violations: violations)
|
|
122
|
+
@final_text = final_text
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def sanitized
|
|
126
|
+
@final_text || @original_text
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
class Middleware
|
|
5
|
+
def initialize(client, &block)
|
|
6
|
+
@client = client
|
|
7
|
+
@guard = Guard.new(&block)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def chat(input, **options)
|
|
11
|
+
@guard.call(input) do |sanitized_input|
|
|
12
|
+
@client.chat(sanitized_input, **options)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
17
|
+
@client.respond_to?(method_name, include_private) || super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def method_missing(method_name, *args, **kwargs, &block)
|
|
23
|
+
if @client.respond_to?(method_name)
|
|
24
|
+
@client.send(method_name, *args, **kwargs, &block)
|
|
25
|
+
else
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Controller
|
|
5
|
+
def self.included(base)
|
|
6
|
+
base.extend(ClassMethods)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module ClassMethods
|
|
10
|
+
# Define guardrails for this controller using the Guard DSL.
|
|
11
|
+
#
|
|
12
|
+
# guardrails do
|
|
13
|
+
# input { check :prompt_injection; check :pii, action: :redact }
|
|
14
|
+
# output { check :pii, action: :redact }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
def guardrails(&block)
|
|
18
|
+
@_guardrails_guard = GuardrailsRuby::Guard.new(&block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def _guardrails_guard
|
|
22
|
+
@_guardrails_guard
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Validate and sanitize user input through the configured guardrails.
|
|
29
|
+
# Defaults to params[:message] if no text is provided.
|
|
30
|
+
# Raises GuardrailsRuby::Blocked if any check has action: :block.
|
|
31
|
+
# Returns the sanitized text otherwise.
|
|
32
|
+
def guarded_input(text = params[:message])
|
|
33
|
+
guard = self.class._guardrails_guard
|
|
34
|
+
return text unless guard
|
|
35
|
+
|
|
36
|
+
result = guard.check_input(text)
|
|
37
|
+
if result.blocked?
|
|
38
|
+
raise GuardrailsRuby::Blocked, result.violations.first&.detail
|
|
39
|
+
end
|
|
40
|
+
result.sanitized
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Validate and sanitize LLM output through the configured guardrails.
|
|
44
|
+
# Raises GuardrailsRuby::Blocked if any check has action: :block.
|
|
45
|
+
# Returns the sanitized text otherwise.
|
|
46
|
+
def guarded_output(text)
|
|
47
|
+
guard = self.class._guardrails_guard
|
|
48
|
+
return text unless guard
|
|
49
|
+
|
|
50
|
+
result = guard.check_output(output: text)
|
|
51
|
+
if result.blocked?
|
|
52
|
+
raise GuardrailsRuby::Blocked, result.violations.first&.detail
|
|
53
|
+
end
|
|
54
|
+
result.sanitized
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "guardrails_ruby.configure" do
|
|
7
|
+
# Auto-load configuration from config/initializers/guardrails.rb if present.
|
|
8
|
+
# The initializer file should call GuardrailsRuby.configure to set defaults.
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "guardrails_ruby.controller" do
|
|
12
|
+
ActiveSupport.on_load(:action_controller) do
|
|
13
|
+
# Make GuardrailsRuby::Controller available to all controllers
|
|
14
|
+
# but don't include it automatically - controllers opt in via:
|
|
15
|
+
# include GuardrailsRuby::Controller
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Redactors
|
|
5
|
+
class KeywordRedactor
|
|
6
|
+
# Initialize with a list of keywords to redact.
|
|
7
|
+
#
|
|
8
|
+
# redactor = KeywordRedactor.new(%w[secret password], replacement: "[HIDDEN]")
|
|
9
|
+
# redactor.redact("The secret password is abc123")
|
|
10
|
+
# # => "The [HIDDEN] [HIDDEN] is abc123"
|
|
11
|
+
#
|
|
12
|
+
def initialize(keywords, replacement: "[REDACTED]")
|
|
13
|
+
@keywords = keywords
|
|
14
|
+
@replacement = replacement
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Redact all occurrences of the configured keywords from text (case-insensitive).
|
|
18
|
+
# Uses word boundary matching to avoid partial-word replacements.
|
|
19
|
+
def redact(text)
|
|
20
|
+
result = text.dup
|
|
21
|
+
@keywords.each do |kw|
|
|
22
|
+
result.gsub!(/\b#{Regexp.escape(kw)}\b/i, @replacement)
|
|
23
|
+
end
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Detect which keywords are present in text. Returns an array of matched keywords.
|
|
28
|
+
def detect(text)
|
|
29
|
+
@keywords.select { |kw| text.match?(/\b#{Regexp.escape(kw)}\b/i) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GuardrailsRuby
|
|
4
|
+
module Redactors
|
|
5
|
+
class PIIRedactor
|
|
6
|
+
# Reuse patterns from Checks::PII
|
|
7
|
+
PATTERNS = GuardrailsRuby::Checks::PII::PATTERNS
|
|
8
|
+
REDACT_MAP = GuardrailsRuby::Checks::PII::REDACT_MAP
|
|
9
|
+
|
|
10
|
+
# Redact all PII from the given text, returning a new string.
|
|
11
|
+
#
|
|
12
|
+
# GuardrailsRuby::Redactors::PIIRedactor.redact("My SSN is 123-45-6789")
|
|
13
|
+
# # => "My SSN is [SSN REDACTED]"
|
|
14
|
+
#
|
|
15
|
+
def self.redact(text, types: nil)
|
|
16
|
+
new(types: types).redact(text)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Initialize with optional type filter.
|
|
20
|
+
#
|
|
21
|
+
# redactor = PIIRedactor.new(types: [:ssn, :email])
|
|
22
|
+
# redactor.redact("Call 555-123-4567, SSN 123-45-6789")
|
|
23
|
+
# # => "Call 555-123-4567, SSN [SSN REDACTED]"
|
|
24
|
+
#
|
|
25
|
+
def initialize(types: nil)
|
|
26
|
+
@types = types&.map(&:to_sym)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Redact PII from text. Returns a new string with PII replaced by placeholders.
|
|
30
|
+
def redact(text)
|
|
31
|
+
result = text.dup
|
|
32
|
+
active_patterns.each do |type, pattern|
|
|
33
|
+
result.gsub!(pattern, REDACT_MAP[type])
|
|
34
|
+
end
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Detect PII in text without redacting. Returns a hash of { type => [matches] }.
|
|
39
|
+
def detect(text)
|
|
40
|
+
found = {}
|
|
41
|
+
active_patterns.each do |type, pattern|
|
|
42
|
+
matches = text.scan(pattern)
|
|
43
|
+
found[type] = matches if matches.any?
|
|
44
|
+
end
|
|
45
|
+
found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def active_patterns
|
|
51
|
+
if @types
|
|
52
|
+
PATTERNS.select { |type, _| @types.include?(type) }
|
|
53
|
+
else
|
|
54
|
+
PATTERNS
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|