prompt-sanitizer 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.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # Represents a single detected PII entity.
5
+ DetectedEntity = Struct.new(
6
+ :entity_type, # Symbol — EntityType constant (e.g. :email)
7
+ :original, # String — the raw PII value found in text
8
+ :replacement, # String — the placeholder token (e.g. "[EMAIL_1]")
9
+ :start_pos, # Integer — character offset in the *original* text
10
+ :end_pos, # Integer — end offset (exclusive) in the *original* text
11
+ :confidence, # Float — detection confidence 0.0..1.0
12
+ :layer, # Symbol — :regex | :secrets | :ner
13
+ keyword_init: true
14
+ )
15
+
16
+ # Returned by Sanitizer#sanitize and Session#anonymize_with_result.
17
+ SanitizeResult = Struct.new(
18
+ :text, # String — sanitized text with PII replaced by tokens
19
+ :original, # String — the original unsanitized input
20
+ :entities, # Array<DetectedEntity> — all entities found
21
+ keyword_init: true
22
+ ) do
23
+ # Number of PII entities detected.
24
+ def count = entities.size
25
+
26
+ # True if any PII was found.
27
+ def any? = entities.any?
28
+
29
+ # Entities filtered by type.
30
+ def by_type(type) = entities.select { |e| e.entity_type == type }
31
+
32
+ # Mapping of original values to their replacement tokens.
33
+ def mapping
34
+ entities.each_with_object({}) { |e, h| h[e.original] = e.replacement }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # Core sanitization class — the main public interface.
5
+ #
6
+ # Ties together RegexEngine, SecretsEngine, optional NEREngine,
7
+ # SyntheticEngine, Vault, and AuditLog.
8
+ #
9
+ # Usage (one-shot):
10
+ #
11
+ # s = PromptSanitizer::Sanitizer.new(mode: :fast)
12
+ # result = s.sanitize("Email me at john@acme.com")
13
+ # result.text # => "Email me at [EMAIL_1]"
14
+ # result.any? # => true
15
+ #
16
+ # Usage (multi-turn with session):
17
+ #
18
+ # sess = s.session(session_id: "user-42")
19
+ # clean = sess.anonymize(user_prompt)
20
+ # raw_response = call_llm(clean)
21
+ # final = sess.deanonymize(raw_response)
22
+ class Sanitizer
23
+ # @param mode [Symbol] :fast (default), :smart, :full
24
+ # @param locale [String] Faker locale (e.g. "en", "fr")
25
+ # @param entities [Array<Symbol>, nil] whitelist; nil = all
26
+ # @param on_detect [Symbol] :redact (default), :warn, :block
27
+ # @param audit_log [Audit::Base, nil] custom audit backend
28
+ # @param ner_backend [Symbol] :informers (default) or :mitie
29
+ # @param ner_model [String] NER model variant
30
+ def initialize(
31
+ mode: :fast,
32
+ locale: "en",
33
+ entities: nil,
34
+ on_detect: :redact,
35
+ audit_log: nil,
36
+ ner_backend: :informers,
37
+ ner_model: "distilbert"
38
+ )
39
+ unless Mode.valid?(mode)
40
+ raise ConfigurationError, "Invalid mode: #{mode.inspect}. Use :fast, :smart, or :full"
41
+ end
42
+
43
+ @mode = mode
44
+ @locale = locale
45
+ @on_detect = on_detect.to_sym
46
+ @allowed = entities ? Array(entities).map(&:to_sym).to_set : nil
47
+
48
+ @regex = Engines::RegexEngine.new
49
+ @secrets = Engines::SecretsEngine.new
50
+ @ner = mode == :fast ? nil : Engines::NEREngine.new(backend: ner_backend, model: ner_model)
51
+
52
+ @synthetic = SyntheticEngine.new(locale: locale)
53
+
54
+ @audit = if audit_log && audit_log != :none
55
+ audit_log
56
+ elsif mode == :full
57
+ Audit::MemoryAuditLog.new
58
+ end
59
+ end
60
+
61
+ # @return [Symbol] active detection mode
62
+ attr_reader :mode
63
+
64
+ # @return [Audit::Base, nil]
65
+ attr_reader :audit
66
+
67
+ # ── Public API ─────────────────────────────────────────────────────────────
68
+
69
+ # Sanitize +text+ in a single-use vault. Returns a SanitizeResult.
70
+ #
71
+ # @param text [String]
72
+ # @param session_id [String, nil] included in audit events
73
+ # @return [SanitizeResult]
74
+ def sanitize(text, session_id: nil)
75
+ _run(text, Vault.new, session_id: session_id)
76
+ end
77
+
78
+ # Sanitize a list of texts, each with its own vault.
79
+ #
80
+ # @param texts [Array<String>]
81
+ # @param session_id [String, nil]
82
+ # @return [Array<SanitizeResult>]
83
+ def sanitize_batch(texts, session_id: nil)
84
+ texts.map { |t| sanitize(t, session_id: session_id) }
85
+ end
86
+
87
+ # Create a Session for multi-turn anonymize/deanonymize workflows.
88
+ # The session maintains a shared vault so the same PII always maps
89
+ # to the same token, and LLM responses can be deanonymized.
90
+ #
91
+ # Without a block, returns a Session you manage yourself.
92
+ # With a block, yields the Session and clears the vault after the block.
93
+ #
94
+ # @param session_id [String, nil]
95
+ # @yieldparam sess [Session]
96
+ # @return [Session, Object] the Session (no block) or block return value
97
+ def session(session_id: nil, &block)
98
+ sess = Session.new(self, session_id: session_id)
99
+ return sess unless block_given?
100
+
101
+ sess.use(&block)
102
+ end
103
+
104
+ # Register a custom regex pattern.
105
+ #
106
+ # @param entity_type [Symbol] e.g. :custom or any EntityType
107
+ # @param pattern [String] Ruby regex string
108
+ # @param confidence [Float]
109
+ def add_pattern(entity_type, pattern, confidence: 0.85)
110
+ @regex.add_pattern(entity_type.to_sym, pattern, confidence: confidence)
111
+ end
112
+
113
+ # Convenience: restore vault tokens in +text+ back to originals.
114
+ # Useful when you hold a vault externally.
115
+ #
116
+ # @param text [String]
117
+ # @param vault [Vault]
118
+ # @return [String]
119
+ def restore(text, vault:)
120
+ vault.restore(text)
121
+ end
122
+
123
+ # ── Internal pipeline (called by Session too) ──────────────────────────────
124
+
125
+ # @api private
126
+ def _run(text, vault, session_id: nil) # rubocop:disable Naming/MethodParameterName
127
+ return SanitizeResult.new(text: text, original: text, entities: []) if text.nil? || text.empty?
128
+
129
+ # 1. Collect detections from all active layers
130
+ raw = []
131
+ raw.concat(@regex.detect(text))
132
+ raw.concat(@secrets.detect(text))
133
+ raw.concat(@ner.detect(text)) if @ner
134
+
135
+ # 2. Filter to allowed entity types
136
+ raw = raw.select { |e| @allowed.include?(e.entity_type) } if @allowed
137
+
138
+ # 3. Deduplicate overlapping spans
139
+ entities = _deduplicate(raw)
140
+
141
+ # 4. Handle on_detect modes
142
+ if @on_detect == :block && entities.any?
143
+ raise PIIDetectedError, entities
144
+ end
145
+
146
+ if @on_detect == :warn
147
+ return SanitizeResult.new(text: text, original: text, entities: entities)
148
+ end
149
+
150
+ # on_detect == :redact (default)
151
+
152
+ # 5. Assign replacements — reuse vault entry when same PII seen before
153
+ entities.each do |entity|
154
+ existing = vault.replacement_for(entity.original)
155
+ if existing
156
+ entity.replacement = existing
157
+ else
158
+ replacement = if @mode == :full
159
+ @synthetic.generate(entity.entity_type, entity.original)
160
+ else
161
+ @synthetic.placeholder(entity.entity_type)
162
+ end
163
+ entity.replacement = vault.add(entity.original, replacement)
164
+ end
165
+ end
166
+
167
+ # 6. Reconstruct text right-to-left to preserve byte offsets
168
+ chars = text.dup
169
+ entities.reverse_each do |entity|
170
+ chars[entity.start_pos...entity.end_pos] = entity.replacement.to_s
171
+ end
172
+
173
+ # 7. Record audit events
174
+ if @audit
175
+ entities.each do |entity|
176
+ method = entity.replacement.to_s.start_with?("[") ? :placeholder : :synthetic
177
+ @audit.record(
178
+ Audit::AuditEvent.new(
179
+ timestamp: Audit.now_iso,
180
+ entity_type: entity.entity_type,
181
+ confidence: entity.confidence,
182
+ layer: entity.layer,
183
+ redaction_method: method,
184
+ text_hash: Audit.hash_value(entity.original),
185
+ session_id: session_id
186
+ )
187
+ )
188
+ end
189
+ end
190
+
191
+ SanitizeResult.new(text: chars, original: text, entities: entities)
192
+ end
193
+
194
+ private
195
+
196
+ # Remove overlapping DetectedEntity spans.
197
+ # Strategy: highest confidence first (then longest span), greedy non-overlap.
198
+ # Returns entities sorted by start_pos.
199
+ def _deduplicate(entities)
200
+ return entities if entities.empty?
201
+
202
+ ranked = entities.sort_by { |e| [-e.confidence, -(e.end_pos - e.start_pos)] }
203
+
204
+ kept = []
205
+ kept_spans = []
206
+
207
+ ranked.each do |entity|
208
+ overlaps = kept_spans.any? do |s, e|
209
+ !(entity.end_pos <= s || entity.start_pos >= e)
210
+ end
211
+
212
+ unless overlaps
213
+ kept << entity
214
+ kept_spans << [entity.start_pos, entity.end_pos]
215
+ end
216
+ end
217
+
218
+ kept.sort_by(&:start_pos)
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # Maintains a shared Vault across multiple anonymize/deanonymize calls.
5
+ #
6
+ # The same PII value always maps to the same replacement token within a
7
+ # session, and LLM responses can be deanonymized back to the originals.
8
+ #
9
+ # Usage:
10
+ #
11
+ # sanitizer = PromptSanitizer::Sanitizer.new(mode: :smart)
12
+ #
13
+ # # Single-use block (vault cleared on exit):
14
+ # sanitizer.session(session_id: "user-42") do |sess|
15
+ # clean = sess.anonymize(user_prompt)
16
+ # response = call_llm(clean)
17
+ # puts sess.deanonymize(response)
18
+ # end
19
+ #
20
+ # # Long-lived (manage lifecycle yourself):
21
+ # sess = sanitizer.session
22
+ # loop { sess.anonymize(...) ... }
23
+ # sess.reset
24
+ class Session
25
+ attr_reader :session_id, :vault
26
+
27
+ # @param sanitizer [Sanitizer]
28
+ # @param session_id [String, nil]
29
+ def initialize(sanitizer, session_id: nil)
30
+ @sanitizer = sanitizer
31
+ @session_id = session_id
32
+ @vault = Vault.new
33
+ end
34
+
35
+ # Sanitize +text+ using the session's shared vault.
36
+ # Returns the redacted string.
37
+ #
38
+ # @param text [String]
39
+ # @return [String]
40
+ def anonymize(text)
41
+ @sanitizer._run(text, @vault, session_id: @session_id).text
42
+ end
43
+
44
+ # Like #anonymize but returns the full SanitizeResult.
45
+ #
46
+ # @param text [String]
47
+ # @return [SanitizeResult]
48
+ def anonymize_with_result(text)
49
+ @sanitizer._run(text, @vault, session_id: @session_id)
50
+ end
51
+
52
+ # Restore vault tokens in +text+ back to the original PII values.
53
+ # Pass the LLM's response here to get a human-readable output.
54
+ #
55
+ # @param text [String]
56
+ # @return [String]
57
+ def deanonymize(text)
58
+ @vault.restore(text)
59
+ end
60
+
61
+ # Snapshot of original → replacement mapping for this session.
62
+ #
63
+ # @return [Hash{String => String}]
64
+ def mapping
65
+ @vault.snapshot
66
+ end
67
+
68
+ # Number of unique PII values currently stored in the session vault.
69
+ #
70
+ # @return [Integer]
71
+ def size
72
+ @vault.snapshot.size
73
+ end
74
+
75
+ # Clear the vault, resetting the session for a fresh conversation.
76
+ def reset
77
+ @vault.clear
78
+ end
79
+
80
+ # Block form — vault is cleared after the block returns.
81
+ #
82
+ # sanitizer.session { |s| s.anonymize(...) }
83
+ #
84
+ # @yieldparam session [Session] self
85
+ # @return the block's return value
86
+ def use
87
+ yield self
88
+ ensure
89
+ reset
90
+ end
91
+
92
+ def inspect
93
+ "#<PromptSanitizer::Session id=#{@session_id.inspect} mappings=#{size}>"
94
+ end
95
+ alias to_s inspect
96
+ end
97
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # Generates realistic fake replacement values per EntityType.
5
+ #
6
+ # When the +faker+ gem is installed, each call produces a
7
+ # contextually appropriate fake (names, emails, IPs, …).
8
+ # Without faker the engine falls back to sequential placeholder
9
+ # tokens: +[EMAIL_1]+, +[PERSON_2]+, etc.
10
+ #
11
+ # Determinism within a session is guaranteed by the Vault: the
12
+ # same original value always receives the same token/fake because
13
+ # Session#anonymize only calls +generate+ once per unique original.
14
+ #
15
+ # Usage:
16
+ #
17
+ # engine = SyntheticEngine.new(locale: "en_US")
18
+ # engine.generate(:email, "john@acme.com") # => "xavier@mailnull.net"
19
+ # engine.generate(:person, "Alice Smith") # => "Carlos Rivera"
20
+ class SyntheticEngine
21
+ CHARS_ALPHA_NUM = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
22
+
23
+ begin
24
+ require "faker"
25
+ HAS_FAKER = true
26
+ rescue LoadError
27
+ HAS_FAKER = false
28
+ end
29
+
30
+ # @param locale [String] BCP-47 locale tag forwarded to Faker (e.g. "en", "fr", "de")
31
+ def initialize(locale: "en")
32
+ @locale = locale
33
+ @counters = Hash.new(0) # entity_type Symbol → Integer
34
+ if HAS_FAKER
35
+ Faker::Config.locale = locale
36
+ end
37
+ end
38
+
39
+ # Returns a fake replacement string for +entity_type+.
40
+ #
41
+ # @param entity_type [Symbol] one of the EntityType constants
42
+ # @param _original [String] original text (unused here; determinism via Vault)
43
+ # @return [String]
44
+ def generate(entity_type, _original = "")
45
+ if HAS_FAKER
46
+ faker_value(entity_type)
47
+ else
48
+ placeholder(entity_type)
49
+ end
50
+ end
51
+
52
+ # Force a placeholder token regardless of faker availability.
53
+ # Used by Session when the replacement must survive round-trips.
54
+ def placeholder(entity_type)
55
+ @counters[entity_type] += 1
56
+ "[#{entity_type.to_s.upcase}_#{@counters[entity_type]}]"
57
+ end
58
+
59
+ def reset!
60
+ @counters.clear
61
+ end
62
+
63
+ private
64
+
65
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
66
+ def faker_value(entity_type) # rubocop:disable Metrics/AbcSize
67
+ case entity_type
68
+ when :person
69
+ Faker::Name.name
70
+ when :email
71
+ Faker::Internet.email
72
+ when :phone
73
+ Faker::PhoneNumber.phone_number
74
+ when :ssn
75
+ "#{rand(100..899).to_s.rjust(3, "0")}-" \
76
+ "#{rand(10..99).to_s.rjust(2, "0")}-" \
77
+ "#{rand(1000..9999)}"
78
+ when :credit_card
79
+ fake_luhn_card
80
+ when :iban
81
+ "GB#{rand(10..99)}MOCK" \
82
+ "#{rand(10_000_000..99_999_999)}" \
83
+ "#{rand(100_000_000_000..999_999_999_999)}"
84
+ when :ip_address
85
+ "#{rand(1..254)}.#{rand(0..255)}.#{rand(0..255)}.#{rand(1..254)}"
86
+ when :mac_address
87
+ Array.new(6) { rand(0..255).to_s(16).rjust(2, "0") }.join(":")
88
+ when :url
89
+ "https://#{Faker::Internet.domain_name}/#{Faker::Internet.slug}"
90
+ when :address
91
+ Faker::Address.full_address.tr("\n", ", ")
92
+ when :zip_code
93
+ Faker::Address.postcode
94
+ when :date
95
+ Faker::Date.between(from: "1990-01-01", to: "2020-12-31").strftime("%m/%d/%Y")
96
+ when :date_of_birth
97
+ Faker::Date.birthday(min_age: 18, max_age: 80).strftime("%m/%d/%Y")
98
+ when :crypto_address
99
+ "0x" + Array.new(40) { rand(0..15).to_s(16) }.join
100
+ when :passport
101
+ "#{("A".."Z").to_a.sample}#{rand(10_000_000..99_999_999)}"
102
+ when :driving_license
103
+ "#{("A".."Z").to_a.sample}#{rand(100_000..999_999)}"
104
+ when :organization
105
+ Faker::Company.name
106
+ when :location
107
+ Faker::Address.city
108
+ when :api_key
109
+ "sk-" + Array.new(48) { CHARS_ALPHA_NUM.sample }.join
110
+ when :jwt, :jwt_token
111
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJSRURBQ1RFRCJ9.REDACTED_SIGNATURE"
112
+ when :bearer_token, :oauth_token
113
+ "REDACTED_" + Array.new(16) { (("A".."Z").to_a + ("0".."9").to_a).sample }.join
114
+ when :aws_access_key
115
+ "AKIA" + Array.new(16) { (("A".."Z").to_a + ("0".."9").to_a).sample }.join
116
+ when :aws_secret_key
117
+ Array.new(40) { (("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a + ["+", "/"]).sample }.join
118
+ when :private_key
119
+ "-----BEGIN PRIVATE KEY-----\nREDACTED\n-----END PRIVATE KEY-----"
120
+ when :db_connection, :database_url
121
+ "postgresql://user:password@localhost:5432/#{Faker::Lorem.word}"
122
+ when :secret_key, :password
123
+ placeholder(entity_type)
124
+ else
125
+ placeholder(entity_type)
126
+ end
127
+ end
128
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
129
+
130
+ # Generates a Luhn-valid 16-digit fake Visa card number.
131
+ def fake_luhn_card
132
+ digits = [4] + Array.new(14) { rand(0..9) }
133
+
134
+ # Calculate check digit — double digits at even indices (0,2,4,...,14)
135
+ # so that in the final 16-digit number they sit at even positions from
136
+ # the right (2,4,...,16) as required by the Luhn algorithm.
137
+ sum = digits.each_with_index.sum do |d, i|
138
+ if i.even?
139
+ doubled = d * 2
140
+ doubled > 9 ? doubled - 9 : doubled
141
+ else
142
+ d
143
+ end
144
+ end
145
+ check = (10 - (sum % 10)) % 10
146
+ digits << check
147
+
148
+ raw = digits.join
149
+ "#{raw[0, 4]} #{raw[4, 4]} #{raw[8, 4]} #{raw[12, 4]}"
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # Thread-safe bidirectional in-memory store for a sanitization session.
5
+ #
6
+ # Maps original PII values → their replacement tokens and vice-versa.
7
+ # Deterministic within a session: the same original always maps to the
8
+ # same replacement. The vault is never persisted — it lives only in memory.
9
+ #
10
+ # Safe for use across Puma threads — all reads and writes are guarded by
11
+ # a Mutex.
12
+ class Vault
13
+ def initialize
14
+ @forward = {} # original → replacement
15
+ @reverse = {} # replacement → original
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ # ── Write ───────────────────────────────────────────────────────────────
20
+
21
+ # Store an original → replacement mapping.
22
+ # If the original is already mapped, the existing replacement is returned
23
+ # (determinism guarantee). Returns the active replacement string.
24
+ def add(original, replacement)
25
+ @mutex.synchronize do
26
+ unless @forward.key?(original)
27
+ @forward[original] = replacement
28
+ @reverse[replacement] = original
29
+ end
30
+ @forward[original]
31
+ end
32
+ end
33
+
34
+ # ── Read ────────────────────────────────────────────────────────────────
35
+
36
+ def replacement_for(original)
37
+ @forward[original]
38
+ end
39
+
40
+ def original_for(replacement)
41
+ @reverse[replacement]
42
+ end
43
+
44
+ # ── Restore ─────────────────────────────────────────────────────────────
45
+
46
+ # Replace all known replacement tokens in +text+ with their originals.
47
+ # Tokens are substituted longest-first to avoid partial matches
48
+ # (e.g. "[EMAIL_1]" before "[EMAIL]").
49
+ def restore(text)
50
+ @mutex.synchronize do
51
+ result = text.dup
52
+ @reverse
53
+ .keys
54
+ .sort_by { |k| -k.length }
55
+ .each { |token| result.gsub!(token, @reverse[token]) }
56
+ result
57
+ end
58
+ end
59
+
60
+ # ── Lifecycle ───────────────────────────────────────────────────────────
61
+
62
+ def clear
63
+ @mutex.synchronize do
64
+ @forward.clear
65
+ @reverse.clear
66
+ end
67
+ end
68
+
69
+ # ── Introspection ────────────────────────────────────────────────────────
70
+
71
+ def size = @forward.size
72
+ alias length size
73
+
74
+ def empty? = @forward.empty?
75
+
76
+ def include?(original) = @forward.key?(original)
77
+
78
+ # Returns a frozen copy of the forward mapping (original → replacement).
79
+ # Safe to call from any thread.
80
+ def snapshot
81
+ @mutex.synchronize { @forward.dup.freeze }
82
+ end
83
+
84
+ def inspect
85
+ "#<#{self.class.name} size=#{size}>"
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ VERSION = "0.1.0"
5
+ end