Stop Leaking PII to LLMs! Use top_secret Instead
Your users' credit cards, social security numbers, and private emails are being fed straight into ChatGPT right now—and you might not even know it.
Here's the terrifying truth that keeps security engineers awake at night: every time your application sends user-generated text to an external API, you're potentially exposing a goldmine of personally identifiable information (PII). That customer support ticket with a credit card number? Straight to the LLM. That user review containing someone's phone number? Logged forever in someone else's training data. That form submission with a social security number? Now property of a third-party service you don't control.
The compliance implications alone are staggering. GDPR violations can cost 4% of global revenue. HIPAA breaches start at $100 per record. And the reputational damage? Often irreversible.
But what if you could automatically strip every piece of sensitive information from text before it ever leaves your servers? What if this protection was so seamless that your developers barely had to change their code? Enter top_secret, the open-source Ruby gem from thoughtbot that's making waves in the developer community for solving one of AI integration's most dangerous blind spots.
This isn't just another data sanitization library. It's a complete PII redaction engine that combines powerful regex pattern matching with machine learning-based named entity recognition (NER) to catch what traditional filters miss. And it's built by thoughtbot, the same engineering team behind some of the most respected tools in the Ruby ecosystem.
Ready to lock down your data pipeline? Let's dive deep into how top_secret works—and why ignoring this problem could cost you everything.
What is top_secret?
top_secret is a Ruby gem designed to automatically detect and redact sensitive information from free-form text before transmission to external services, APIs, chatbots, and large language models (LLMs). Released by thoughtbot, the renowned software consultancy behind tools like FactoryBot, Clearance, and Administrate, this library addresses a critical gap in modern AI integration workflows.
The gem operates on a deceptively simple principle with profound implications: intercept outbound text, identify sensitive patterns, replace them with reversible placeholders, and maintain a mapping for restoration. This architecture means you can send "clean" text to third parties while retaining the ability to reconstruct the original message when responses return.
What makes top_secret particularly compelling in 2024 is the explosive growth of LLM integrations. Every SaaS product seems to be adding "AI features," yet few engineering teams have implemented robust PII protection at the application layer. The default filters cover the most common leak vectors: credit cards, emails, phone numbers, social security numbers, people's names, and locations. But the real power lies in its extensibility—you can define custom filters for domain-specific sensitive data.
The gem leverages MITIE (MIT Information Extraction), a proven C++ library with Ruby bindings, for its NER capabilities. This isn't toy machine learning—MITIE has been used in production systems for years and provides confidence scores for each entity detection. The hybrid approach (regex + NER) means structured data like credit card numbers get caught by bulletproof patterns, while unstructured data like person names benefit from contextual ML understanding.
Critically, top_secret is designed for reversible redaction. Unlike destructive sanitization that permanently removes data, the gem maintains a mapping dictionary that enables two-way transformation. This is essential for LLM workflows where you need to send clean text, get a response, then restore the original values in the final output.
Key Features That Make top_secret Insanely Powerful
Six Battle-Tested Default Filters
The gem ships with production-ready filters covering the highest-risk data categories:
credit_card_filter— Matches common credit card formats using precise regex patternsemail_filter— Catches standard email addressesphone_number_filter— Detects US phone number formatsssn_filter— Identifies US Social Security numberspeople_filter— ML-powered name detection via NERlocation_filter— ML-powered geographic entity detection
Dual-Engine Detection Architecture
top_secret combines two fundamentally different approaches:
| Engine | Technology | Best For | Performance |
|---|---|---|---|
| Regex | Ruby regular expressions | Structured data (CC, SSN, email) | Lightning fast |
| NER | MITIE ML model | Unstructured entities (names, places) | Slower but smarter |
This hybrid design means you get speed where you need it and intelligence where it matters. Regex filters execute in microseconds. NER filters understand that "Washington" could be a person, place, or organization based on context.
Reversible Redaction with Full Audit Trail
Every filtering operation returns a Result object containing:
- Original input — Never lost, always accessible
- Redacted output — Safe to transmit externally
- Mapping dictionary — Enables perfect restoration
- Sensitivity flags —
sensitive?andsafe?methods for quick checks - Category-specific queries —
emails?,people?,phone_numbers?and more
Batch Processing with Consistent Labeling
When processing multiple related messages, filter_all ensures identical values receive consistent placeholder labels across the entire batch. This is crucial for LLM conversations where context matters—ralph@thoughtbot.com becomes [EMAIL_1] in every message, not [EMAIL_1] in one and [EMAIL_4] in another.
Seamless LLM Integration
The gem includes explicit support for round-trip LLM workflows: filter → send to API → receive response → restore original values. There's even a companion gem, ruby_llm-top_secret, for automatic integration with the popular RubyLLM library.
Highly Configurable and Extensible
Override, disable, or augment any default filter. Add custom regex patterns for domain-specific data. Train new NER models for specialized entity types. The configuration API is clean and Ruby-idiomatic.
Use Cases: Where top_secret Saves Your Bacon
1. Customer Support Chatbots
Your helpdesk bot needs to understand "My card ending in 4242 was charged twice on January 15th." Without redaction, that credit card number hits the LLM provider's servers. With top_secret, it becomes "My card ending in [CREDIT_CARD_1] was charged twice on January 15th"—fully understandable by the AI, completely safe to transmit.
2. Healthcare AI Assistants
HIPAA compliance is non-negotiable. When patients describe symptoms and mention "Dr. Smith at Massachusetts General," both the doctor's name and hospital location get automatically redacted. The LLM can still provide relevant medical information without ever receiving protected health information (PHI).
3. Financial Services Document Processing
Loan applications, insurance claims, and tax documents contain SSNs, account numbers, and income details. Before sending these to document analysis APIs, top_secret strips all identifiers while preserving the semantic content needed for processing.
4. Legal Tech and E-Discovery
Law firms increasingly use AI for document review. But privileged communications contain attorney names, client identities, and case locations. Automated redaction ensures ethical walls remain intact when using external AI services.
5. Social Media Content Moderation
Platforms moderating user-generated content often use third-party AI services. But reports frequently contain PII: "This user @johndoe1985 from Austin is harassing me." Redact before sending to preserve reporter anonymity.
Step-by-Step Installation & Setup Guide
Prerequisites
Before installing, you'll need to download the MITIE NER model:
# Download the model file (large binary, ~100MB+)
curl -L -o MITIE-models-v0.2.tar.bz2 \
https://github.com/mit-nlp/MITIE/releases/download/v0.4/MITIE-models-v0.2.tar.bz2
# Extract the NER model specifically
tar -xjf MITIE-models-v0.2.tar.bz2
cp MITIE-models/english/ner_model.dat ./ner_model.dat
Pro tip: Add
ner_model.datto your.gitignoreimmediately. This file is too large for version control and should be deployed via Trove or similar asset management in production.
Gem Installation
Add to your Gemfile:
bundle add top_secret
Or install directly:
gem install top_secret
Basic Configuration
Create an initializer (e.g., config/initializers/top_secret.rb in Rails):
TopSecret.configure do |config|
# Point to your downloaded model (default: project root)
config.model_path = Rails.root.join("vendor", "ner_model.dat").to_s
# Optional: adjust NER confidence threshold (default: 0.25)
config.min_confidence_score = 0.5
end
Performance-Optimized Setup (No NER)
If you only need regex-based filtering and want to eliminate the model dependency:
TopSecret.configure do |config|
config.model_path = nil # Disables people and location filters
end
This is ideal for high-throughput environments where you only need to catch structured PII like credit cards, emails, and SSNs.
Production Deployment
For zero-downtime deployments, eager-load the model to avoid cold-start penalties:
# config/application.rb or initializer
config.after_initialize do
if TopSecret.model_path && File.exist?(TopSecret.model_path)
TopSecret::Text.shared_model # Pre-load into shared cache
end
end
Deploy the model with Trove:
# Rakefile
Rake::Task["assets:precompile"].enhance do
Trove.pull # Downloads model during deployment
end
REAL Code Examples from the Repository
Example 1: Basic Filtering and Result Inspection
The simplest possible usage—pass a string, get back a fully instrumented result:
# Single-pass redaction with complete metadata
result = TopSecret::Text.filter("Ralph can be reached at ralph@thoughtbot.com")
This returns a TopSecret::Text::Result object:
<TopSecret::Text::Result
@input="Ralph can be reached at ralph@thoughtbot.com",
@mapping={:EMAIL_1=>"ralph@thoughtbot.com", :PERSON_1=>"Ralph"},
@output="[PERSON_1] can be reached at [EMAIL_1]"
>
Why this matters: The @mapping hash is your reversible encryption key. Without it, you can never restore the original values. Always persist this alongside any external API responses if you need round-trip capability.
Inspect individual components:
result.input # Original: "Ralph can be reached at ralph@thoughtbot.com"
result.output # Safe to send: "[PERSON_1] can be reached at [EMAIL_1]"
result.mapping # Restoration key: {:EMAIL_1=>"ralph@thoughtbot.com", :PERSON_1=>"Ralph"}
# Quick safety checks
result.sensitive? # => true (found PII)
result.safe? # => false (not safe to send as-is)
Example 2: Category-Specific Queries
The generated category methods provide ergonomic access to specific data types without manual mapping traversal:
result = TopSecret::Text.filter(
"Ralph can be reached at ralph@example.com or 555-1234"
)
# Boolean checks for presence
result.emails? # => true
result.people? # => true
result.phone_numbers? # => true
# Extract actual values (useful for logging or separate processing)
result.emails # => ["ralph@example.com"]
result.people # => ["Ralph"]
result.phone_numbers # => ["555-1234"]
# Get label-to-value mappings for restoration
result.email_mapping # => {:EMAIL_1=>"ralph@example.com"}
result.person_mapping # => {:PERSON_1=>"Ralph"}
result.phone_number_mapping # => {:PHONE_NUMBER_1=>"555-1234"}
# See which categories were detected
result.categories # => [:email, :person, :phone_number]
Critical insight: These methods are always defined and return empty collections when no matches exist. This eliminates defensive nil-checking and makes your code cleaner:
result = TopSecret::Text.filter("No sensitive data here")
result.emails? # => false
result.emails # => [] (never nil!)
result.email_mapping # => {} (never nil!)
Example 3: Complete LLM Round-Trip Workflow
This is where top_secret shines—full bidirectional transformation with external AI services:
require "openai"
require "top_secret"
# Initialize your LLM client
openai = OpenAI::Client.new(
api_key: Rails.application.credentials.openai.api_key!
)
# Original user messages containing PII
original_messages = [
"Ralph lives in Boston.",
"You can reach them at ralph@thoughtbot.com or 877-976-2687"
]
# STEP 1: Filter ALL messages with consistent labeling
result = TopSecret::Text.filter_all(original_messages)
filtered_messages = result.items.map(&:output)
# => ["[PERSON_1] lives in [LOCATION_1].",
# "You can reach them at [EMAIL_1] or [PHONE_NUMBER_1]"]
# STEP 2: Build messages for LLM
user_messages = filtered_messages.map { {role: "user", content: it} }
# CRITICAL: Instruct LLM to preserve placeholders in responses
instructions = <<~TEXT
I'm going to send filtered information to you in the form of free text.
If you need to refer to the filtered information in a response, just reference it by the filter.
TEXT
messages = [
{role: "system", content: instructions},
*user_messages
]
# STEP 3: Send to LLM (safe—no PII leaves your infrastructure)
chat_completion = openai.chat.completions.create(messages:, model: :"gpt-5")
response = chat_completion.choices.last.message.content
# LLM might return: "I'll email [EMAIL_1] about [PERSON_1]'s request"
# STEP 4: Restore original values from the mapping
mapping = result.mapping # {:PERSON_1=>"Ralph", :LOCATION_1=>"Boston", ...}
restored_response = TopSecret::FilteredText.restore(
response,
mapping:
).output
# => "I'll email ralph@thoughtbot.com about Ralph's request"
puts(restored_response)
The instructions variable is non-negotiable. Without explicitly telling the LLM to preserve placeholder syntax, you risk getting back responses like "I'll email the person about their request"—making restoration impossible. Always include this system prompt.
Example 4: Custom Regex Filter for Domain-Specific Data
Extend top_secret for your organization's unique sensitive data:
# Define a custom filter for internal IP addresses
ip_address_filter = TopSecret::Filters::Regex.new(
label: "IP_ADDRESS", # Must start/end with letter, use single underscores
regex: /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/
)
# Apply alongside defaults
result = TopSecret::Text.filter(
"Ralph's IP address is 192.168.1.1",
custom_filters: [ip_address_filter]
)
result.output
# => "[PERSON_1]'s IP address is [IP_ADDRESS_1]"
result.mapping
# => {:PERSON_1=>"Ralph", :IP_ADDRESS_1=>"192.168.1.1"}
Label naming rules are strict—they must start and end with letters, contain only letters and single underscores, and cannot include digits. This ensures clean method generation: IP_ADDRESS produces ip_addresses, ip_addresses?, and ip_address_mapping.
Example 5: Scanning Without Redaction
Sometimes you only need detection, not transformation—like pre-flight checks before API calls:
# Scan returns mapping without modifying text
result = TopSecret::Text.scan("Ralph can be reached at ralph@thoughtbot.com")
result.sensitive? # => true
result.mapping # => {:EMAIL_1=>"ralph@thoughtbot.com", :PERSON_1=>"Ralph"}
# No @output or @input—just the detection metadata
# Perfect for: alerting, logging, access control decisions
Advanced Usage & Best Practices
Batch Consistency Is Essential for Conversations
Always use filter_all for related messages. Inconsistent labeling breaks context:
# BAD: Separate filter calls assign independent labels
msg1 = TopSecret::Text.filter("Contact ralph@thoughtbot.com") # [EMAIL_1]
msg2 = TopSecret::Text.filter("Email ralph@thoughtbot.com") # [EMAIL_1] (different instance!)
# GOOD: filter_all ensures global consistency
result = TopSecret::Text.filter_all([
"Contact ralph@thoughtbot.com",
"Email ralph@thoughtbot.com",
"Also CC ruby@thoughtbot.com"
])
# ralph@thoughtbot.com => [EMAIL_1] in ALL messages
# ruby@thoughtbot.com => [EMAIL_2]
Performance: Cache, Cache, Cache
The MITIE model load is expensive. The gem automatically caches after first use, but production deployments should eager-load:
# In your Puma/unicorn worker boot or Rails initializer
TopSecret::Text.shared_model if TopSecret.model_path
Disable NER for Pure Speed
If your threat model only requires structured data protection, eliminate the ML overhead:
TopSecret.configure { |c| c.model_path = nil }
This reduces memory footprint and eliminates the model file dependency entirely.
Custom NER for Specialized Domains
Train MITIE on your corpus for domain-specific entities—medical terms, legal entities, product codes. The mitie-ruby training API makes this accessible.
Comparison with Alternatives
| Feature | top_secret | manual regex | cloud DLP APIs | presidio |
|---|---|---|---|---|
| Self-hosted | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| Ruby-native | ✅ Yes | ✅ Yes | ❌ SDK varies | ❌ Python |
| Reversible | ✅ Built-in | ❌ Manual | ⚠️ Varies | ⚠️ Complex |
| ML-based NER | ✅ MITIE | ❌ No | ✅ Often | ✅ spaCy |
| Custom filters | ✅ Easy | ⚠️ Manual | ⚠️ Config | ✅ Code |
| LLM round-trip | ✅ Designed for | ❌ No | ❌ No | ⚠️ Possible |
| Cost | Free | Free | $$$ per request | Free |
| Offline capable | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
Why top_secret wins: It's the only solution designed specifically for the LLM round-trip workflow with native Ruby ergonomics. Cloud DLP services introduce latency, cost, and data sovereignty concerns. Manual regex misses unstructured entities. Presidio requires Python infrastructure. top_secret fills the gap for Ruby teams building AI features.
FAQ
Does top_secret work with non-English text?
The regex filters work with any text. The NER filters depend on MITIE's English model by default. You can train MITIE models for other languages, but this requires additional setup.
How accurate is the NER detection?
MITIE provides confidence scores for each detection. You can tune min_confidence_score to balance precision vs. recall. Lower values catch more (with more false positives); higher values are more conservative.
Can I use top_secret without the MITIE model?
Absolutely. Set config.model_path = nil to disable NER entirely. You'll retain all regex-based filters (credit cards, emails, phones, SSNs) with zero model overhead.
Is the redaction cryptographically secure?
No—and it's not meant to be. The placeholders are reversible through the mapping, which must be protected like any sensitive data. For cryptographic needs, use proper encryption before redaction.
How do I handle LLMs that don't preserve placeholders?
Always include explicit instructions in your system prompt, as shown in the examples. If an LLM still fails to preserve placeholders, you'll get unrestored markers in the FilteredText::Result#unrestored array for handling.
Does this integrate with Rails?
Yes, though there's no official Rails engine. Configure in an initializer, use in controllers/services, and consider wrapping in ActiveSupport::Notifications for monitoring.
What about batch processing large datasets?
Use filter_all for related messages. For truly large-scale processing, consider parallelization with the NER-disabled configuration, or process in background jobs with Sidekiq.
Conclusion
Every day you delay implementing PII redaction is another day your users' data leaks into systems you don't control. The compliance risks are real. The reputational damage is permanent. And the fix? It's one gem away.
top_secret by thoughtbot isn't just a utility—it's architectural insurance for the AI-powered applications you're building right now. Its hybrid regex+NER engine catches what simple patterns miss. Its reversible design enables seamless LLM integration. And its Ruby-native API means your team can ship protection in hours, not weeks.
The repository is actively maintained, battle-tested by thoughtbot's client work, and ready for production. Don't wait for your first data incident to take this seriously.
👉 Star, fork, and deploy top_secret today. Your users' privacy—and your company's future—depend on it.
What sensitive data is slipping through your API calls right now? Install top_secret and find out before someone else does.