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,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Engines
5
+ # Secrets Engine — Layer 1 of prompt-sanitizer (secrets branch).
6
+ #
7
+ # Detects credentials, API keys, tokens, and connection strings that should
8
+ # never reach an LLM. Runs alongside the RegexEngine on every sanitize() call.
9
+ class SecretsEngine
10
+ Pattern = Struct.new(:entity_type, :regex, :confidence, :label, keyword_init: true)
11
+
12
+ PATTERNS = [
13
+ # ── JWT (header.payload.signature) ────────────────────────────────────
14
+ Pattern.new(
15
+ entity_type: EntityType::JWT,
16
+ regex: /eyJ[a-zA-Z0-9_\-]{10,}\.eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,}/,
17
+ confidence: 0.99,
18
+ label: "JWT"
19
+ ),
20
+
21
+ # ── Bearer token ──────────────────────────────────────────────────────
22
+ Pattern.new(
23
+ entity_type: EntityType::BEARER_TOKEN,
24
+ regex: /(?:Authorization\s*:\s*)?Bearer\s+([a-zA-Z0-9_\-\.]{20,})/i,
25
+ confidence: 0.92,
26
+ label: "Bearer token"
27
+ ),
28
+
29
+ # ── AWS Access Key ID ──────────────────────────────────────────────────
30
+ Pattern.new(
31
+ entity_type: EntityType::AWS_ACCESS_KEY,
32
+ regex: /(?<![A-Z0-9])(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}(?![A-Z0-9])/,
33
+ confidence: 0.99,
34
+ label: "AWS access key ID"
35
+ ),
36
+
37
+ # ── AWS Secret Access Key (context-anchored) ───────────────────────────
38
+ Pattern.new(
39
+ entity_type: EntityType::AWS_SECRET_KEY,
40
+ regex: /(?:aws_secret(?:_access)?_key|secret_access_key)\s*[=:"'\s]\s*([a-zA-Z0-9+\/]{40})/i,
41
+ confidence: 0.97,
42
+ label: "AWS secret access key"
43
+ ),
44
+
45
+ # ── OpenAI API key ─────────────────────────────────────────────────────
46
+ Pattern.new(
47
+ entity_type: EntityType::API_KEY,
48
+ regex: /sk-(?:proj-|org-)?[a-zA-Z0-9_\-T]{20,}/,
49
+ confidence: 0.97,
50
+ label: "OpenAI API key"
51
+ ),
52
+
53
+ # ── Anthropic API key ──────────────────────────────────────────────────
54
+ Pattern.new(
55
+ entity_type: EntityType::API_KEY,
56
+ regex: /sk-ant-(?:api\d{2}-)?[a-zA-Z0-9_\-]{20,}/,
57
+ confidence: 0.99,
58
+ label: "Anthropic API key"
59
+ ),
60
+
61
+ # ── GitHub Personal Access Token (classic) ─────────────────────────────
62
+ Pattern.new(
63
+ entity_type: EntityType::API_KEY,
64
+ regex: /ghp_[a-zA-Z0-9]{36}/,
65
+ confidence: 0.99,
66
+ label: "GitHub PAT (classic)"
67
+ ),
68
+
69
+ # ── GitHub Fine-grained PAT ────────────────────────────────────────────
70
+ Pattern.new(
71
+ entity_type: EntityType::API_KEY,
72
+ regex: /github_pat_[a-zA-Z0-9_]{82}/,
73
+ confidence: 0.99,
74
+ label: "GitHub fine-grained PAT"
75
+ ),
76
+
77
+ # ── GitHub OAuth / server-to-server / refresh ──────────────────────────
78
+ Pattern.new(
79
+ entity_type: EntityType::API_KEY,
80
+ regex: /(?:gho|ghs|ghr)_[a-zA-Z0-9]{36}/,
81
+ confidence: 0.99,
82
+ label: "GitHub OAuth/server token"
83
+ ),
84
+
85
+ # ── Slack tokens ───────────────────────────────────────────────────────
86
+ Pattern.new(
87
+ entity_type: EntityType::API_KEY,
88
+ regex: /xox[baprs]-(?:[0-9a-zA-Z]{4,}-)+[0-9a-zA-Z]{4,}/,
89
+ confidence: 0.98,
90
+ label: "Slack token"
91
+ ),
92
+
93
+ # ── Stripe secret / publishable key ───────────────────────────────────
94
+ Pattern.new(
95
+ entity_type: EntityType::API_KEY,
96
+ regex: /(?:sk|pk)_(?:live|test)_[a-zA-Z0-9]{24,}/,
97
+ confidence: 0.99,
98
+ label: "Stripe API key"
99
+ ),
100
+
101
+ # ── Twilio Account SID ─────────────────────────────────────────────────
102
+ Pattern.new(
103
+ entity_type: EntityType::API_KEY,
104
+ regex: /AC[a-f0-9]{32}/,
105
+ confidence: 0.90,
106
+ label: "Twilio Account SID"
107
+ ),
108
+
109
+ # ── Twilio Auth Token ──────────────────────────────────────────────────
110
+ Pattern.new(
111
+ entity_type: EntityType::API_KEY,
112
+ regex: /(?:auth_token|TWILIO_AUTH_TOKEN)\s*[=:"'\s]\s*([a-f0-9]{32})/i,
113
+ confidence: 0.97,
114
+ label: "Twilio Auth Token"
115
+ ),
116
+
117
+ # ── Google API key ─────────────────────────────────────────────────────
118
+ Pattern.new(
119
+ entity_type: EntityType::API_KEY,
120
+ regex: /AIza[0-9A-Za-z_\-]{35}/,
121
+ confidence: 0.99,
122
+ label: "Google API key"
123
+ ),
124
+
125
+ # ── SendGrid API key ───────────────────────────────────────────────────
126
+ Pattern.new(
127
+ entity_type: EntityType::API_KEY,
128
+ regex: /SG\.[a-zA-Z0-9_\-]{22}\.[a-zA-Z0-9_\-]{43}/,
129
+ confidence: 0.99,
130
+ label: "SendGrid API key"
131
+ ),
132
+
133
+ # ── Mailchimp API key ──────────────────────────────────────────────────
134
+ Pattern.new(
135
+ entity_type: EntityType::API_KEY,
136
+ regex: /[a-f0-9]{32}-us\d{1,2}/,
137
+ confidence: 0.90,
138
+ label: "Mailchimp API key"
139
+ ),
140
+
141
+ # ── HuggingFace token ──────────────────────────────────────────────────
142
+ Pattern.new(
143
+ entity_type: EntityType::API_KEY,
144
+ regex: /hf_[a-zA-Z0-9]{34,}/,
145
+ confidence: 0.99,
146
+ label: "HuggingFace token"
147
+ ),
148
+
149
+ # ── PEM private key header ─────────────────────────────────────────────
150
+ Pattern.new(
151
+ entity_type: EntityType::PRIVATE_KEY,
152
+ regex: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY-----/,
153
+ confidence: 0.99,
154
+ label: "PEM private key"
155
+ ),
156
+
157
+ # ── Database connection strings ────────────────────────────────────────
158
+ Pattern.new(
159
+ entity_type: EntityType::DB_CONNECTION,
160
+ regex: /(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|mssql|sqlserver|oracle|clickhouse|cassandra|couchdb|neo4j):\/\/[^\s'"`<>\n]{8,}/i,
161
+ confidence: 0.97,
162
+ label: "Database connection string"
163
+ ),
164
+
165
+ # ── Generic secret assignment (SECRET_KEY=..., api_key: '...') ─────────
166
+ Pattern.new(
167
+ entity_type: EntityType::API_KEY,
168
+ regex: /(?:secret[_\-]?key|api[_\-]?key|access[_\-]?token|auth[_\-]?token|private[_\-]?key|client[_\-]?secret)\s*[=:"'\s]+\s*([a-zA-Z0-9_\-\.+\/]{16,})/i,
169
+ confidence: 0.80,
170
+ label: "Generic secret assignment"
171
+ ),
172
+ ].freeze
173
+
174
+ # ── Instance ─────────────────────────────────────────────────────────────
175
+
176
+ def initialize
177
+ @patterns = PATTERNS.dup
178
+ end
179
+
180
+ # Register a custom secret pattern at runtime.
181
+ def add_pattern(entity_type, regex, label: "custom secret", confidence: 0.85)
182
+ @patterns << Pattern.new(
183
+ entity_type: entity_type,
184
+ regex: regex,
185
+ confidence: confidence,
186
+ label: label
187
+ )
188
+ end
189
+
190
+ # Scan +text+ for secrets and return an Array of DetectedEntity.
191
+ # When the pattern has a capturing group, the captured value and its
192
+ # offsets are used; otherwise the full match is used.
193
+ def detect(text)
194
+ safe_text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
195
+ entities = []
196
+
197
+ @patterns.each do |pat|
198
+ safe_text.scan(pat.regex) do
199
+ m = Regexp.last_match
200
+
201
+ # Use capture group 1 when present (strips surrounding context)
202
+ if m.captures.any?
203
+ value = m[1]
204
+ start_pos = m.begin(1)
205
+ end_pos = m.end(1)
206
+ else
207
+ value = m[0]
208
+ start_pos = m.begin(0)
209
+ end_pos = m.end(0)
210
+ end
211
+
212
+ next if value.nil? || value.empty?
213
+
214
+ entities << DetectedEntity.new(
215
+ entity_type: pat.entity_type,
216
+ original: value,
217
+ replacement: nil,
218
+ start_pos: start_pos,
219
+ end_pos: end_pos,
220
+ confidence: pat.confidence,
221
+ layer: :secrets
222
+ )
223
+ end
224
+ end
225
+
226
+ entities
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # All PII / sensitive entity types the gem can detect.
5
+ # Values are symbols — mirror the Python/JS string enum names.
6
+ module EntityType
7
+ # ── Personal identifiers ──────────────────────────────────────────────
8
+ PERSON = :person
9
+ EMAIL = :email
10
+ PHONE = :phone
11
+ DATE_OF_BIRTH = :date_of_birth
12
+ AGE = :age
13
+
14
+ # ── Location ──────────────────────────────────────────────────────────
15
+ ADDRESS = :address
16
+ ZIP_CODE = :zip_code
17
+
18
+ # ── Financial ─────────────────────────────────────────────────────────
19
+ CREDIT_CARD = :credit_card
20
+ IBAN = :iban
21
+ BANK_ACCOUNT = :bank_account
22
+ CRYPTO_ADDRESS = :crypto_address
23
+
24
+ # ── Government IDs ────────────────────────────────────────────────────
25
+ SSN = :ssn
26
+ PASSPORT = :passport
27
+ DRIVING_LICENSE = :driving_license
28
+
29
+ # ── Network / Digital ─────────────────────────────────────────────────
30
+ IP_ADDRESS = :ip_address
31
+ MAC_ADDRESS = :mac_address
32
+ URL = :url
33
+
34
+ # ── Secrets & credentials ─────────────────────────────────────────────
35
+ API_KEY = :api_key
36
+ JWT = :jwt
37
+ BEARER_TOKEN = :bearer_token
38
+ AWS_ACCESS_KEY = :aws_access_key
39
+ AWS_SECRET_KEY = :aws_secret_key
40
+ PRIVATE_KEY = :private_key
41
+ DB_CONNECTION = :db_connection
42
+
43
+ # ── Temporal ──────────────────────────────────────────────────────────
44
+ DATE = :date
45
+
46
+ # ── NER-only (detected by SMART / FULL mode) ──────────────────────────
47
+ ORGANIZATION = :organization
48
+ LOCATION = :location
49
+ MISC = :misc
50
+
51
+ # ── User-defined ──────────────────────────────────────────────────────
52
+ CUSTOM = :custom
53
+
54
+ ALL = constants(false).map { |c| const_get(c) }.freeze
55
+ end
56
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Integrations
5
+ # ActionController concern — adds PII sanitization helpers to controllers.
6
+ #
7
+ # Include in ApplicationController or a specific controller:
8
+ #
9
+ # class ApplicationController < ActionController::Base
10
+ # include PromptSanitizer::Integrations::ActionControllerConcern
11
+ # end
12
+ #
13
+ # Then in any action:
14
+ #
15
+ # # Sanitize specific params in-place before using them:
16
+ # def create
17
+ # sanitize_params!(:message, :prompt)
18
+ # call_llm(params[:message])
19
+ # end
20
+ #
21
+ # # Multi-turn session scoped to one action (vault cleared after block):
22
+ # def chat
23
+ # with_pii_session do |sess|
24
+ # clean = sess.anonymize(params[:message])
25
+ # response = call_llm(clean)
26
+ # render json: { reply: sess.deanonymize(response) }
27
+ # end
28
+ # end
29
+ module ActionControllerConcern
30
+ # Sanitize one or more param keys in-place.
31
+ #
32
+ # @param keys [Array<Symbol, String>] param keys whose string values
33
+ # should be sanitized. Nested paths not supported (flatten first).
34
+ # @return [void]
35
+ def sanitize_params!(*keys)
36
+ keys.flatten.each do |key|
37
+ next unless params[key].is_a?(String)
38
+
39
+ result = pii_sanitizer.sanitize(params[key])
40
+ params[key] = result.text
41
+ end
42
+ end
43
+
44
+ # Yield a Session scoped to this action's PII vault.
45
+ # The vault is always cleared after the block, even on exception.
46
+ #
47
+ # @yieldparam session [PromptSanitizer::Session]
48
+ # @return the block's return value
49
+ def with_pii_session(session_id: nil, &block)
50
+ id = session_id || "#{controller_name}##{action_name}"
51
+ pii_sanitizer.session(session_id: id, &block)
52
+ end
53
+
54
+ # The Sanitizer instance used by this controller.
55
+ # Defaults to the global PromptSanitizer.sanitizer.
56
+ # Override in a controller to use a custom instance.
57
+ #
58
+ # @return [PromptSanitizer::Sanitizer]
59
+ def pii_sanitizer
60
+ PromptSanitizer.sanitizer
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Integrations
5
+ # ActiveJob concern — sanitize job arguments before the job is enqueued.
6
+ #
7
+ # Prevents PII from leaking into job queues (Sidekiq, Resque, GoodJob, etc.)
8
+ # where job arguments are serialized and often stored in plain text.
9
+ #
10
+ # == Usage
11
+ #
12
+ # class SummarizeJob < ApplicationJob
13
+ # include PromptSanitizer::Integrations::ActiveJobConcern
14
+ # sanitize_argument :prompt
15
+ #
16
+ # def perform(prompt:)
17
+ # # `prompt` is already sanitized here
18
+ # call_llm(prompt)
19
+ # end
20
+ # end
21
+ #
22
+ # == How it works
23
+ #
24
+ # +sanitize_argument+ registers an +around_perform+ callback that sanitizes
25
+ # the named keyword argument(s) before +perform+ is called and restores them
26
+ # in the job's return context if needed.
27
+ #
28
+ # The original PII never touches the queue backend — the redacted version is
29
+ # enqueued and the job works with sanitized text.
30
+ module ActiveJobConcern
31
+ extend ActiveSupport::Concern
32
+
33
+ included do
34
+ # Class-level list of keyword argument names to sanitize.
35
+ class_attribute :_pii_sanitized_arguments, default: []
36
+ end
37
+
38
+ class_methods do
39
+ # Declare which keyword arguments should be sanitized before perform.
40
+ #
41
+ # @param args [Array<Symbol>] keyword argument names
42
+ def sanitize_argument(*args)
43
+ self._pii_sanitized_arguments = _pii_sanitized_arguments | args.map(&:to_sym)
44
+
45
+ around_perform do |job, block|
46
+ sanitizer = PromptSanitizer.sanitizer
47
+ session = sanitizer.session(session_id: job.job_id)
48
+
49
+ # Sanitize the declared keyword arguments in job_data / arguments.
50
+ # ActiveJob stores keyword args as the last Hash element in arguments.
51
+ kwargs = job.arguments.last
52
+ if kwargs.is_a?(Hash)
53
+ _pii_sanitized_arguments.each do |key|
54
+ str_key = key.to_s
55
+ if kwargs[key].is_a?(String)
56
+ kwargs[key] = session.anonymize(kwargs[key])
57
+ elsif kwargs[str_key].is_a?(String)
58
+ kwargs[str_key] = session.anonymize(kwargs[str_key])
59
+ end
60
+ end
61
+ end
62
+
63
+ block.call
64
+ ensure
65
+ session&.reset
66
+ end
67
+ end
68
+ end
69
+
70
+ # Manually sanitize a string using the global sanitizer.
71
+ #
72
+ # @param text [String]
73
+ # @return [String] sanitized text
74
+ def sanitize_pii(text)
75
+ PromptSanitizer.sanitizer.sanitize(text).text
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Integrations
5
+ # Rack middleware — auto-sanitizes incoming JSON request bodies.
6
+ #
7
+ # Intercepts POST/PUT/PATCH requests with Content-Type: application/json
8
+ # and sanitizes the following payload keys before they reach the app:
9
+ #
10
+ # - +messages[*].content+ (OpenAI / Anthropic / LangChain style)
11
+ # - +prompt+, +input+, +inputs+, +text+, +query+
12
+ #
13
+ # The sanitized body is written back into the Rack env so that
14
+ # downstream controllers see clean payloads with no raw PII.
15
+ #
16
+ # Optionally, if +restore_response: true+, the middleware deanonymizes
17
+ # JSON response bodies using the same session vault before sending them
18
+ # to the client.
19
+ #
20
+ # == Usage (manually)
21
+ #
22
+ # use PromptSanitizer::Integrations::SanitizerMiddleware,
23
+ # sanitizer: PromptSanitizer.sanitizer,
24
+ # routes: ["/api/llm"],
25
+ # restore_response: false
26
+ #
27
+ # == Usage (via Railtie config)
28
+ #
29
+ # config.prompt_sanitizer.middleware = true
30
+ # config.prompt_sanitizer.middleware_routes = ["/api/llm"]
31
+ class SanitizerMiddleware
32
+ # Keys in a flat JSON body whose string values are sanitized.
33
+ BODY_KEYS = %w[prompt input inputs text query message content].freeze
34
+
35
+ # @param app [#call] next Rack app
36
+ # @param sanitizer [Sanitizer] defaults to PromptSanitizer.sanitizer
37
+ # @param routes [Array<String>, nil] path prefixes to match (nil = all)
38
+ # @param restore_response [Boolean] deanonymize JSON response? (default false)
39
+ def initialize(app, sanitizer: nil, routes: nil, restore_response: false)
40
+ @app = app
41
+ @san = sanitizer || PromptSanitizer.sanitizer
42
+ @routes = routes ? Array(routes) : nil
43
+ @restore = restore_response
44
+ end
45
+
46
+ def call(env)
47
+ req = Rack::Request.new(env)
48
+
49
+ session = nil
50
+
51
+ if should_process?(req)
52
+ session = _sanitize_request(req)
53
+ end
54
+
55
+ status, headers, body = @app.call(env)
56
+
57
+ if session && @restore
58
+ status, headers, body = _restore_response(status, headers, body, session)
59
+ end
60
+
61
+ [status, headers, body]
62
+ end
63
+
64
+ private
65
+
66
+ def should_process?(req)
67
+ return false unless %w[POST PUT PATCH].include?(req.request_method)
68
+ return false unless req.content_type&.include?("application/json")
69
+
70
+ return true if @routes.nil?
71
+
72
+ @routes.any? { |r| req.path.start_with?(r) }
73
+ end
74
+
75
+ def _sanitize_request(req)
76
+ raw = req.body.read
77
+ req.body.rewind
78
+ return nil if raw.nil? || raw.empty?
79
+
80
+ begin
81
+ payload = JSON.parse(raw)
82
+ rescue JSON::ParserError
83
+ return nil
84
+ end
85
+
86
+ session = @san.session
87
+ _sanitize_payload!(payload, session)
88
+
89
+ # Write the sanitized body back so downstream sees clean params.
90
+ sanitized_raw = JSON.generate(payload)
91
+ env = req.env
92
+ env["rack.input"] = StringIO.new(sanitized_raw)
93
+ env["CONTENT_LENGTH"] = sanitized_raw.bytesize.to_s
94
+ # Invalidate any already-parsed params cache.
95
+ env.delete("rack.request.form_hash")
96
+ env.delete("rack.request.form_input")
97
+
98
+ session
99
+ rescue StandardError
100
+ nil
101
+ end
102
+
103
+ def _sanitize_payload!(obj, session)
104
+ case obj
105
+ when Hash
106
+ # messages array (OpenAI / LangChain style)
107
+ if obj["messages"].is_a?(Array)
108
+ obj["messages"].each do |msg|
109
+ next unless msg.is_a?(Hash) && msg["content"].is_a?(String)
110
+
111
+ msg["content"] = session.anonymize(msg["content"])
112
+ end
113
+ end
114
+
115
+ BODY_KEYS.each do |key|
116
+ obj[key] = session.anonymize(obj[key]) if obj[key].is_a?(String)
117
+ end
118
+ when Array
119
+ obj.each { |item| _sanitize_payload!(item, session) }
120
+ end
121
+ end
122
+
123
+ def _restore_response(status, headers, body, session)
124
+ content_type = headers["Content-Type"] || headers["content-type"] || ""
125
+ return [status, headers, body] unless content_type.include?("application/json")
126
+
127
+ raw = +""
128
+ body.each { |chunk| raw << chunk }
129
+
130
+ begin
131
+ obj = JSON.parse(raw)
132
+ restored = _restore_obj(obj, session)
133
+ new_body = JSON.generate(restored)
134
+ headers["Content-Length"] = new_body.bytesize.to_s
135
+ [status, headers, [new_body]]
136
+ rescue JSON::ParserError
137
+ [status, headers, [raw]]
138
+ end
139
+ ensure
140
+ body.close if body.respond_to?(:close)
141
+ end
142
+
143
+ def _restore_obj(obj, session)
144
+ case obj
145
+ when String then session.deanonymize(obj)
146
+ when Hash then obj.transform_values { |v| _restore_obj(v, session) }
147
+ when Array then obj.map { |v| _restore_obj(v, session) }
148
+ else obj
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ module Mode
5
+ # Regex + secrets only. Sub-millisecond. Zero dependencies.
6
+ # Catches: email, phone, SSN, credit card, IBAN, IP, crypto, MAC, URL,
7
+ # API keys, JWTs, bearer tokens, AWS keys, DB connection strings,
8
+ # private keys, passport, driving licence, date-of-birth.
9
+ FAST = :fast
10
+
11
+ # FAST + NER (informers/distilbert or mitie). ~25–50 ms on CPU.
12
+ # Additionally catches: names, organisations, locations, misc entities.
13
+ # Requires: gem "informers" (or gem "mitie")
14
+ SMART = :smart
15
+
16
+ # Everything in SMART plus synthetic replacements and audit logging.
17
+ # Requires: gem "informers", gem "faker"
18
+ FULL = :full
19
+
20
+ ALL = [FAST, SMART, FULL].freeze
21
+
22
+ def self.valid?(mode)
23
+ ALL.include?(mode)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptSanitizer
4
+ # Rails::Railtie — hooks prompt-sanitizer into the Rails boot process.
5
+ #
6
+ # Loaded automatically when Rails is present (detected in prompt_sanitizer.rb).
7
+ # Exposes a configuration namespace under +config.prompt_sanitizer+
8
+ # so Rails apps can tune the gem from an initializer without monkey-patching.
9
+ #
10
+ # Generated initializer (from +rails g prompt_sanitizer:install+)::
11
+ #
12
+ # PromptSanitizer.configure do |c|
13
+ # c.mode = :smart
14
+ # c.locale = "en"
15
+ # c.audit_log = :memory
16
+ # end
17
+ #
18
+ # Middleware (optional)::
19
+ #
20
+ # config.prompt_sanitizer.middleware = true # all routes
21
+ # config.prompt_sanitizer.middleware_routes = ["/api/llm", "/chat"]
22
+ # config.prompt_sanitizer.restore_response = false
23
+ class Railtie < Rails::Railtie
24
+ # Expose a config object on the Rails::Application config.
25
+ config.prompt_sanitizer = ActiveSupport::OrderedOptions.new
26
+ config.prompt_sanitizer.middleware = false
27
+ config.prompt_sanitizer.middleware_routes = nil
28
+ config.prompt_sanitizer.restore_response = false
29
+
30
+ # After all initializers run, insert middleware if requested.
31
+ initializer "prompt_sanitizer.insert_middleware", after: :load_config_initializers do |app|
32
+ cfg = app.config.prompt_sanitizer
33
+ if cfg.middleware
34
+ require_relative "integrations/middleware"
35
+ app.middleware.use(
36
+ PromptSanitizer::Integrations::SanitizerMiddleware,
37
+ sanitizer: PromptSanitizer.sanitizer,
38
+ routes: cfg.middleware_routes,
39
+ restore_response: cfg.restore_response
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end