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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +507 -0
  3. data/Gemfile +2 -0
  4. data/LICENSE +21 -0
  5. data/README.md +243 -0
  6. data/Rakefile +9 -0
  7. data/examples/basic.rb +64 -0
  8. data/examples/custom_check.rb +103 -0
  9. data/examples/rails_controller.rb +73 -0
  10. data/guardrails-ruby.gemspec +30 -0
  11. data/lib/guardrails_ruby/check.rb +64 -0
  12. data/lib/guardrails_ruby/checks/competitor_mention.rb +36 -0
  13. data/lib/guardrails_ruby/checks/encoding.rb +33 -0
  14. data/lib/guardrails_ruby/checks/format.rb +35 -0
  15. data/lib/guardrails_ruby/checks/hallucinated_emails.rb +30 -0
  16. data/lib/guardrails_ruby/checks/hallucinated_urls.rb +38 -0
  17. data/lib/guardrails_ruby/checks/keyword_filter.rb +33 -0
  18. data/lib/guardrails_ruby/checks/max_length.rb +30 -0
  19. data/lib/guardrails_ruby/checks/pii.rb +54 -0
  20. data/lib/guardrails_ruby/checks/prompt_injection.rb +36 -0
  21. data/lib/guardrails_ruby/checks/relevance.rb +43 -0
  22. data/lib/guardrails_ruby/checks/topic.rb +25 -0
  23. data/lib/guardrails_ruby/checks/toxic_language.rb +28 -0
  24. data/lib/guardrails_ruby/configuration.rb +15 -0
  25. data/lib/guardrails_ruby/guard.rb +129 -0
  26. data/lib/guardrails_ruby/middleware.rb +30 -0
  27. data/lib/guardrails_ruby/rails/controller.rb +57 -0
  28. data/lib/guardrails_ruby/rails/railtie.rb +20 -0
  29. data/lib/guardrails_ruby/redactors/keyword_redactor.rb +33 -0
  30. data/lib/guardrails_ruby/redactors/pii_redactor.rb +59 -0
  31. data/lib/guardrails_ruby/result.rb +53 -0
  32. data/lib/guardrails_ruby/version.rb +5 -0
  33. data/lib/guardrails_ruby/violation.rb +41 -0
  34. data/lib/guardrails_ruby.rb +38 -0
  35. 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