A complete line-by-line breakdown of how ShieldPrompt works, what each piece does, and how they connect.
- What is ShieldPrompt?
- Architecture Flowchart
- File Structure
- Module-by-Module Breakdown
- entities.py - PII Type Definitions
- vault.py - Token Storage Engine
- detector/regex_engine.py - Pattern Matching
- detector/ner_engine.py - AI-Based Detection
- detector/hybrid.py - Combined Detection
- shield.py - Core Engine
- decorator.py - Function Wrapper
- middleware.py - FastAPI Integration
- cli.py - Command Line Tool
- mcp_server.py - Claude Code Integration
- init.py - Package Exports
- main.py - Module Runner
- Data Flow Diagrams
- Key Design Patterns
ShieldPrompt is a privacy library that acts as a bridge between your app and LLMs. It:
- Detects PII (emails, phone numbers, names, SSNs, etc.) in text
- Replaces each PII with a safe token like
[EMAIL_ADDRESS_1] - Sends the sanitized text to the LLM
- Restores the original values in the LLM's response
Your sensitive data never leaves your machine.
SHIELDPROMPT ARCHITECTURE
================================================================
User Input (contains PII)
|
v
+------------------+
| ENTRY POINTS | <-- How users interact with ShieldPrompt
| (pick one) |
+------------------+
| - Shield API | Direct Python: shield.mask(text)
| - @mask_pii | Decorator on functions
| - Middleware | FastAPI auto-shielding
| - CLI | Terminal: shieldprompt mask "..."
| - MCP Server | Claude Code integration
+--------+---------+
|
v
+------------------+
| SHIELD ENGINE | shield.py - Orchestrates everything
| (shield.py) |
+--------+---------+
|
v
+------------------+
| DETECTION | Finds PII in text
+------------------+
| |
v v
+--------+ +--------+
| Regex | | NER | regex_engine.py / ner_engine.py
| Engine | | Engine |
+--------+ +--------+
| Emails | | Names |
| Phones | | Orgs |
| SSNs | | Places |
| Cards | | Dates |
| IPs | | Money |
| URLs | +---+----+
| Keys | |
+---+----+ |
| |
v v
+------------------+
| HYBRID MERGER | hybrid.py - Combines both results
| (hybrid.py) | Prefers regex for overlaps
+--------+---------+
|
| List of Detection objects
v
+------------------+
| VAULT | vault.py - The token<->value map
| (vault.py) |
+------------------+
| store("alice@ |
| example.com") |
| --> returns |
| [EMAIL_ADDRESS_1]|
+--------+---------+
|
v
+------------------+
| MASKED OUTPUT | PII replaced with tokens
+------------------+
| "Email |
| [EMAIL_ADDRESS_1] |
| about meeting |
| with [PERSON_1]" |
+--------+---------+
|
v
Send to LLM safely
|
v
+------------------+
| LLM RESPONSE | LLM responds using tokens
+------------------+
| "Sure, I'll email |
| [EMAIL_ADDRESS_1] |
| to notify |
| [PERSON_1]" |
+--------+---------+
|
v
+------------------+
| UNMASK (vault) | vault.restore_text()
+------------------+
| Replaces tokens |
| with originals |
+--------+---------+
|
v
+------------------+
| FINAL OUTPUT | Real values restored
+------------------+
| "Sure, I'll email |
| alice@example.com |
| to notify Alice" |
+------------------+
@mask_pii()
def call_llm(prompt): User calls call_llm("Email alice@example.com")
return llm.generate(prompt) |
v
+------------------------+
| 1. Create Shield+Vault |
| 2. Set context var |
| 3. Mask "prompt" arg |
+----------+-------------+
|
call_llm("Email [EMAIL_ADDRESS_1]")
|
v
+------------------------+
| 4. Original func runs |
| with masked input |
+----------+-------------+
|
LLM returns "[EMAIL_ADDRESS_1] notified"
|
v
+------------------------+
| 5. Unmask response |
| 6. Reset context |
+----------+-------------+
|
Returns "alice@example.com notified"
HTTP Request HTTP Response
POST /chat 200 OK
{"prompt": "Email alice@..."} {"reply": "Emailed alice@..."}
| ^
v |
+----------+ +----------+
| Read | | Unmask |
| request | | response |
| body | | body |
+----+-----+ +----+-----+
| ^
v |
+----------+ +----------+
| JSON | | Buffer |
| parse & | | response |
| mask all | | parts |
| strings | +----------+
+----+-----+ ^
| |
v |
+----+-----+ +-----+----+
| Forward | ---------> App ---------> | App |
| masked | (sees only tokens) | response |
| request | | (tokens) |
+----------+ +----------+
src/shieldprompt/
__init__.py Package exports (Shield, Vault, mask_pii, etc.)
__main__.py Enables `python -m shieldprompt`
shield.py Core masking/unmasking engine
vault.py Token <-> real value storage
entities.py PII entity type definitions (enum)
decorator.py @mask_pii decorator for functions
middleware.py FastAPI/Starlette ASGI middleware
cli.py Command-line interface (mask/unmask/inspect)
mcp_server.py MCP server for Claude Code integration
detector/
__init__.py Exports detectors
regex_engine.py Fast pattern matching (emails, phones, etc.)
ner_engine.py NLP model detection (names, orgs, etc.)
hybrid.py Combines regex + NER results
Purpose: Defines all the types of PII that ShieldPrompt can detect.
# Line 3: Import Python's Enum class for creating named constants
from enum import Enum
# Line 6: EntityType inherits from both str and Enum.
# Inheriting str means each value IS a string (EntityType.EMAIL_ADDRESS == "EMAIL_ADDRESS")
# This makes it easy to serialize to JSON or use in string formatting
class EntityType(str, Enum):Regex-detected entities (Lines 8-17): These are detected by pattern matching — deterministic and fast.
| Entity | Example |
|---|---|
EMAIL_ADDRESS |
alice@example.com |
PHONE_NUMBER |
(555) 123-4567 |
CREDIT_CARD |
4111-1111-1111-1111 |
SSN |
123-45-6789 |
IP_ADDRESS |
192.168.1.1 |
IBAN |
GB29 NWBK 6016 1331 9268 19 |
DATE_OF_BIRTH |
(currently no regex pattern) |
URL |
https://example.com |
AWS_KEY |
AKIAIOSFODNN7EXAMPLE |
API_KEY |
sk-abc123..., ghp_abc123... |
NER-detected entities (Lines 20-24): These need an AI model (BERT) because they depend on context, not patterns.
| Entity | Example |
|---|---|
PERSON |
"Alice", "John Smith" |
ORGANIZATION |
"Google", "MIT" |
LOCATION |
"New York", "France" |
DATE |
"last Tuesday" |
MONEY |
"$500" |
Sets (Lines 27-58):
# Line 27-38: REGEX_ENTITIES - set of all pattern-detectable types
REGEX_ENTITIES = {EntityType.EMAIL_ADDRESS, EntityType.PHONE_NUMBER, ...}
# Line 40-46: NER_ENTITIES - set of all NER-detectable types
NER_ENTITIES = {EntityType.PERSON, EntityType.ORGANIZATION, ...}
# Line 48: ALL_ENTITIES - union of both sets (| is set union operator)
ALL_ENTITIES = REGEX_ENTITIES | NER_ENTITIES
# Line 50-58: DEFAULT_ENTITIES - the most common PII types
# Used when the user doesn't specify what to detect
DEFAULT_ENTITIES = {EMAIL_ADDRESS, PHONE_NUMBER, CREDIT_CARD, SSN, PERSON, ORGANIZATION, LOCATION}Why these defaults? They cover the most common privacy-sensitive data without requiring every possible type.
Purpose: Maps PII tokens ([EMAIL_ADDRESS_1]) to real values (alice@example.com). Lives only in memory, never persisted to disk automatically.
# Line 5-6: Two key imports for thread safety
import contextvars # Python's async-safe thread-local storage
import threading # For mutual exclusion locksContext Variable (Lines 10-12):
# This creates a "context variable" — like a thread-local variable but also works
# with async/await. Each async task or thread gets its own vault automatically.
_vault_var: contextvars.ContextVar[Optional["Vault"]] = contextvars.ContextVar(
"shieldprompt_vault", default=None
)Why contextvars? If you have 100 concurrent API requests in a FastAPI app, each needs its own vault so PII from request A doesn't leak into request B. contextvars ensures this automatically.
Vault Class (Lines 15-63):
class Vault:
def __init__(self) -> None:
# Line 19: token -> real value. E.g., {"[EMAIL_ADDRESS_1]": "alice@example.com"}
self._token_to_real: dict[str, str] = {}
# Line 20: Reverse mapping for idempotency. E.g., {"alice@example.com": "[EMAIL_ADDRESS_1]"}
self._real_to_token: dict[str, str] = {}
# Line 21: Counts per entity type. E.g., {"EMAIL_ADDRESS": 2, "PERSON": 1}
self._counters: dict[str, int] = {}
# Line 22: Thread lock — prevents two threads from creating tokens simultaneously
self._lock = threading.Lock()store() method (Lines 24-36) — The core of the vault:
def store(self, entity_type: str, real_value: str) -> str:
with self._lock: # Acquire lock (thread safety)
if real_value in self._real_to_token: # Already seen this value?
return self._real_to_token[real_value] # Return same token (idempotent)
count = self._counters.get(entity_type, 0) + 1 # Increment counter
self._counters[entity_type] = count # Save new count
token = f"[{entity_type}_{count}]" # Build token string
self._token_to_real[token] = real_value # Map token -> real
self._real_to_token[real_value] = token # Map real -> token
return tokenKey insight: Idempotency. If alice@example.com appears 5 times in the text, it always maps to [EMAIL_ADDRESS_1]. This keeps the masked text consistent.
restore_text() method (Lines 42-50) — Unmask all tokens in text:
def restore_text(self, text: str) -> str:
result = text
# Sort by token length DESCENDING to avoid partial replacement
# Without this: [PERSON_1] might be partially replaced before [PERSON_10]
for token, real in sorted(
self._token_to_real.items(), key=lambda x: len(x[0]), reverse=True
):
result = result.replace(token, real)
return resultWhy sort by length? If you have [PERSON_1] and [PERSON_10], replacing [PERSON_1] first would corrupt [PERSON_10] into <real_value>0]. Longest-first prevents this.
Context helper functions (Lines 65-81):
# get_vault(): Get the vault for THIS context (thread/async task)
# Creates one automatically if none exists
def get_vault() -> Vault:
vault = _vault_var.get()
if vault is None:
vault = Vault()
_vault_var.set(vault)
return vault
# set_vault(): Explicitly set a vault, returns a reset token
# The reset token lets you restore the previous vault later
def set_vault(vault: Vault) -> contextvars.Token:
return _vault_var.set(vault)
# reset_vault(): Restore the vault to what it was before set_vault()
def reset_vault(token: contextvars.Token) -> None:
_vault_var.reset(token)Purpose: Fast, deterministic PII detection using compiled regex patterns.
Detection dataclass (Lines 12-19):
@dataclass
class Detection:
entity_type: str # E.g., "EMAIL_ADDRESS"
value: str # E.g., "alice@example.com"
start: int # Start position in text
end: int # End position in text
score: float = 1.0 # Confidence (always 1.0 for regex — it's certain)Luhn Algorithm (Lines 22-32) — Credit card validation:
def _luhn_check(number: str) -> bool:
digits = [int(d) for d in number if d.isdigit()] # Extract only digits
if len(digits) < 13: # Real cards have 13-19 digits
return False
odd_digits = digits[-1::-2] # Every other digit from the right
even_digits = digits[-2::-2] # The other digits
total = sum(odd_digits)
for d in even_digits:
total += sum(divmod(d * 2, 10)) # Double, then sum digits if > 9
return total % 10 == 0 # Valid if divisible by 10Why Luhn? Not every 16-digit number is a credit card. The Luhn algorithm is the industry-standard checksum that real credit card numbers satisfy. This prevents false positives like random 16-digit numbers.
Pattern definitions (Lines 36-105):
Each pattern is a tuple of (entity_type, compiled_regex, optional_validator):
| Pattern | Regex Explanation |
|---|---|
\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b — word boundary, local part, @, domain, TLD (2+ letters) |
|
| Phone | (?<!\d)(\+?1[\s-.]?)?((\d{3})[\s-.]?)\d{3}[\s-.]?\d{4}(?!\d) — optional +1 country code, 3-3-4 digit groups, with separators |
| Credit Card | \b(\d[ -]*?){13,19}\b — 13-19 digits with optional spaces/dashes, plus Luhn validation |
| SSN | `\b(?!000 |
| IP Address | `\b(25[0-5] |
| IBAN | \b[A-Z]{2}\d{2}[\s]?[\dA-Z]{4}... — country code + check digits + account |
| URL | https?://[^\s<>"']{3,} — http or https followed by non-whitespace chars |
| AWS Key | `\b(AKIA |
| API Key | `\b(sk-[A-Za-z0-9]{20+} |
RegexDetector class (Lines 108-135):
class RegexDetector:
def __init__(self, entities=None):
self._entities = entities # Optional filter: only detect these types
def detect(self, text: str) -> list[Detection]:
detections = []
for entity_type, pattern, validator in _PATTERNS:
# Skip this pattern if user only wants specific entity types
if self._entities and entity_type not in self._entities:
continue
for match in pattern.finditer(text): # Find ALL matches
value = match.group()
# If there's a validator (e.g., Luhn for credit cards), check it
if validator and not validator(value):
continue
detections.append(Detection(
entity_type=entity_type,
value=value,
start=match.start(),
end=match.end(),
))
return detectionsPurpose: Detects contextual PII (names, organizations, locations) using a pre-trained BERT model. This catches things regex can't — like knowing "Apple" is a company vs. a fruit.
Label mapping (Lines 14-34):
# NER models output labels like "PER", "B-PER", "I-PER"
# We map all variations to our EntityType values
_LABEL_MAP = {
"PER": EntityType.PERSON, # Different models use different labels
"PERSON": EntityType.PERSON, # for the same entity type
"B-PER": EntityType.PERSON, # B- prefix = "beginning of entity"
"I-PER": EntityType.PERSON, # I- prefix = "inside/continuation of entity"
# ... same pattern for ORG, LOC, DATE, MONEY
}Why so many mappings? Different NER models use different labeling schemes (BIO tagging, simple labels, etc.). This map normalizes them all.
NERDetector class (Lines 37-97):
class NERDetector:
def __init__(self, model_name="dslim/bert-base-NER", entities=None):
self._model_name = model_name
self._entities = entities
self._pipeline = None # Lazy loaded — model isn't loaded until first use
def _load(self):
if self._pipeline is not None: # Already loaded
return
# Import transformers only when needed (it's a heavy dependency)
from transformers import pipeline
self._pipeline = pipeline(
"ner", # Named Entity Recognition task
model=self._model_name, # dslim/bert-base-NER by default
aggregation_strategy="simple", # Merge sub-word tokens into whole entities
)Why lazy loading? The BERT model is ~400MB. Loading it at import time would slow down every startup, even if NER is never used.
def detect(self, text: str) -> list[Detection]:
self._load() # Load model if needed
results = self._pipeline(text) # Run NER inference
detections = []
for ent in results:
label = ent.get("entity_group", ent.get("entity", ""))
mapped = _LABEL_MAP.get(label) # Convert model label to our type
if mapped is None: # Unknown label, skip
continue
if self._entities and mapped not in self._entities: # Not requested
continue
value = text[ent["start"]:ent["end"]] # Extract the actual text
if len(value.strip()) < 2: # Skip noise (single chars)
continue
detections.append(Detection(
entity_type=mapped,
value=value,
start=ent["start"],
end=ent["end"],
score=float(ent.get("score", 0.0)), # Confidence from model
))
return detectionsPurpose: Runs both regex and NER, then intelligently merges the results.
Constructor (Lines 21-50):
class HybridDetector:
def __init__(self, entities=None, ner_model="dslim/bert-base-NER", ner_threshold=0.5):
self._ner_threshold = ner_threshold
# Always create regex detector for pattern-based entities
regex_entities = entities & REGEX_ENTITIES if entities else None
self._regex = RegexDetector(entities=regex_entities)
# Only create NER detector if NER entity types are requested
self._ner = None
need_ner = entities is None or bool(entities & NER_ENTITIES)
if need_ner:
try:
from .ner_engine import NERDetector # Import inside try block
ner_entities = entities & NER_ENTITIES if entities else None
self._ner = NERDetector(model_name=ner_model, entities=ner_entities)
except ImportError:
# NER deps not installed — gracefully fall back to regex only
logger.info("NER dependencies not installed. Using regex-only detection.")Why the try/except? NER requires transformers and torch (~2GB of dependencies). If they're not installed, ShieldPrompt still works with regex-only detection.
detect() method (Lines 52-67):
def detect(self, text: str) -> list[Detection]:
detections = self._regex.detect(text) # Always run regex first (fast)
if self._ner: # If NER is available...
try:
ner_detections = self._ner.detect(text)
# Filter out low-confidence NER results
ner_detections = [d for d in ner_detections if d.score >= self._ner_threshold]
detections = self._merge(detections, ner_detections)
except Exception as e:
logger.warning("NER detection failed, using regex only: %s", e)
return detections_merge() method (Lines 69-87) — The smart merger:
@staticmethod
def _merge(regex_dets, ner_dets) -> list[Detection]:
merged = list(regex_dets) # Start with all regex results
regex_spans = {(d.start, d.end) for d in regex_dets} # Track regex spans
for nd in ner_dets:
# Does this NER detection overlap with ANY regex detection?
overlaps = any(nd.start < re and nd.end > rs for rs, re in regex_spans)
if not overlaps:
merged.append(nd) # Only add NER detection if no overlap
merged.sort(key=lambda d: d.start) # Sort by position for orderly processing
return mergedWhy prefer regex over NER for overlaps? Regex gives exact matches with 100% confidence (an email IS an email). NER might detect the same text as a PERSON. Regex wins because it's more precise.
Purpose: The main class that orchestrates detection and masking/unmasking. This is what users interact with.
Constructor (Lines 27-51):
class Shield:
def __init__(self, entities=None, use_ner=None, ner_model="dslim/bert-base-NER",
ner_threshold=0.5, vault=None):
# Normalize entity types: accept strings or EntityType enums
self._entities = (
{EntityType(e) if isinstance(e, str) else e for e in entities}
if entities else DEFAULT_ENTITIES
)
self._vault = vault # Use provided vault, or get from context later
# Smart detector selection:
needs_ner = bool(self._entities & NER_ENTITIES) # Any NER types requested?
if use_ner is False or not needs_ner:
self._detector = RegexDetector(entities=self._entities) # Fast path
else:
self._detector = HybridDetector( # Full detection
entities=self._entities, ner_model=ner_model, ner_threshold=ner_threshold,
)Key decision: If the user only asks for EMAIL_ADDRESS and PHONE_NUMBER (both regex types), NER is never loaded. This keeps things fast when you don't need AI.
vault property (Lines 53-55):
@property
def vault(self) -> Vault:
return self._vault if self._vault else get_vault()Uses the explicitly provided vault, OR falls back to the context-local vault.
mask() method (Lines 57-71) — The heart of ShieldPrompt:
def mask(self, text: str) -> str:
detections = self._detector.detect(text) # Step 1: Find all PII
if not detections:
return text # No PII found, return as-is
# Step 2: Sort DESCENDING by position
# WHY? If we replace from left to right, each replacement shifts all
# subsequent positions. Replacing from right to left avoids this.
detections.sort(key=lambda d: d.start, reverse=True)
result = text
for det in detections:
# Step 3: Store in vault and get token
token = self.vault.store(det.entity_type, det.value)
# Step 4: Replace the PII text with the token
result = result[:det.start] + token + result[det.end:]
return resultExample walkthrough:
Input: "Email alice@example.com, call 555-1234"
^ ^
pos 6-23 pos 31-39
Detections (sorted descending by start):
1. PHONE_NUMBER at pos 31-39 ("555-1234")
2. EMAIL_ADDRESS at pos 6-23 ("alice@example.com")
Replace PHONE first (rightmost):
"Email alice@example.com, call [PHONE_NUMBER_1]"
Positions 0-30 unchanged!
Replace EMAIL next (leftmost):
"Email [EMAIL_ADDRESS_1], call [PHONE_NUMBER_1]"
If we did LEFT to RIGHT instead:
Replace EMAIL: "Email [EMAIL_ADDRESS_1], call 555-1234"
Now "555-1234" is no longer at position 31-39! Its position shifted.
The second replacement would corrupt the text.
unmask() method (Lines 73-75):
def unmask(self, text: str) -> str:
return self.vault.restore_text(text) # Delegates to vaultmask_and_track() method (Lines 77-80):
def mask_and_track(self, text: str) -> tuple[str, dict[str, str]]:
masked = self.mask(text)
return masked, self.vault.mappings # Returns both masked text AND the mapping dictUseful for debugging or auditing.
Purpose: The @mask_pii decorator automatically masks function inputs and unmasks outputs. Zero code changes to your existing LLM calls.
Decorator factory (Lines 15-50):
def mask_pii(
entities=None, # Which PII to detect
use_ner=None, # Auto-detect NER need
param_name="prompt", # Which function argument to mask
mask_kwargs=None, # Additional kwargs to mask
unmask_response=True, # Whether to unmask the return value
) -> Callable:
# Convert entity names to EntityType enums
entity_set = {EntityType(e) for e in entities} if entities else DEFAULT_ENTITIESInner decorator (Lines 52-140):
def decorator(func):
sig = inspect.signature(func) # Capture function signature for argument binding
def _create_shield():
vault = Vault() # Fresh vault per call (no cross-call leakage)
shield = Shield(entities=entity_set, vault=vault, ...)
return shield, vault
def _mask_args(shield, args, kwargs):
bound = sig.bind(*args, **kwargs) # Bind positional/keyword args to param names
bound.apply_defaults() # Fill in default values
# Mask the target parameter
if param_name in bound.arguments:
val = bound.arguments[param_name]
if isinstance(val, str):
bound.arguments[param_name] = shield.mask(val)
else:
# Fallback: mask the first string argument (any name)
for name, val in bound.arguments.items():
if isinstance(val, str):
bound.arguments[name] = shield.mask(val)
break
# Mask any additional kwargs specified in mask_kwargs
if mask_kwargs:
for kw in mask_kwargs:
if kw in bound.arguments and isinstance(bound.arguments[kw], str):
bound.arguments[kw] = shield.mask(bound.arguments[kw])
return tuple(bound.args), bound.kwargs
def _unmask_result(shield, result):
if not unmask_response:
return result
if isinstance(result, str): # String -> unmask directly
return shield.unmask(result)
if isinstance(result, dict): # Dict -> unmask all string values
return {k: shield.unmask(v) if isinstance(v, str) else v
for k, v in result.items()}
return result # Anything else -> pass throughSync vs Async handling (Lines 111-140):
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
shield, vault = _create_shield()
ctx_token = set_vault(vault) # Set vault in async context
try:
new_args, new_kwargs = _mask_args(shield, args, kwargs)
result = await func(*new_args, **new_kwargs) # await for async
return _unmask_result(shield, result)
finally:
reset_vault(ctx_token) # Always clean up context
return async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
shield, vault = _create_shield()
ctx_token = set_vault(vault)
try:
new_args, new_kwargs = _mask_args(shield, args, kwargs)
result = func(*new_args, **new_kwargs)
return _unmask_result(shield, result)
finally:
reset_vault(ctx_token)
return sync_wrapperWhy finally? Even if the wrapped function throws an exception, the context vault is always cleaned up. This prevents memory leaks.
Purpose: ASGI middleware that automatically masks ALL incoming request bodies and unmasks ALL outgoing response bodies. Add one line to your FastAPI app and every endpoint gets privacy protection.
Sensitivity levels (Lines 15-23):
_SENSITIVITY_MAP = {
"low": {EMAIL_ADDRESS, CREDIT_CARD, SSN}, # Just the critical stuff
"medium": DEFAULT_ENTITIES, # Common PII (default)
"high": ALL_ENTITIES, # Everything including URLs, API keys
}Middleware factory (Lines 26-167):
def ShieldPromptMiddleware(app, sensitivity="medium", exclude_paths=None, ...):
# Import starlette types (only needed if middleware is used)
from starlette.types import ASGIApp, Receive, Scope, Send, MessageRequest handling (Lines 65-99):
# For each HTTP request:
vault = Vault() # Fresh vault per request
shield = Shield(entities=entity_set, vault=vault)
# Read the ENTIRE request body (may arrive in chunks)
body_parts = []
while True:
message = await receive()
body_parts.append(message.get("body", b""))
if not message.get("more_body", False):
break
full_body = b"".join(body_parts)
# Try to parse as JSON and mask all string values
try:
data = json.loads(full_body)
masked_data = _mask_json(shield, data) # Recursive masking
masked_body = json.dumps(masked_data).encode()
except (json.JSONDecodeError, UnicodeDecodeError):
pass # Not JSON? Pass through unchangedRecursive JSON masking (Lines 170-178):
def _mask_json(shield, data):
if isinstance(data, str): # String -> mask it
return shield.mask(data)
if isinstance(data, dict): # Dict -> recurse into values
return {k: _mask_json(shield, v) for k, v in data.items()}
if isinstance(data, list): # List -> recurse into items
return [_mask_json(shield, item) for item in data]
return data # Numbers, booleans, null -> pass throughThis handles ANY JSON structure: nested objects, arrays of objects, deeply nested strings.
Response handling (Lines 117-165):
# Buffer the response body (may arrive in chunks)
async def masked_send(message):
if message["type"] == "http.response.start":
start_message = message # Save headers, don't send yet
return
if message["type"] == "http.response.body":
response_parts.append(body)
if not more: # Last chunk
full_resp = b"".join(response_parts)
unmasked = shield.unmask(full_resp.decode()) # Unmask!
full_resp = unmasked.encode()
# Update Content-Length header (unmasked text may differ in size)
# Then send everythingWhy buffer the response? The app might stream the response in chunks. We need the FULL response to unmask properly (a token might be split across chunks).
Purpose: Terminal interface for masking/unmasking/inspecting text.
Three commands:
mask command (Lines 94-108):
def _cmd_mask(args):
text = _get_text(args) # From arg, file, or stdin
entities = {EntityType(e) for e in args.entities} if args.entities else None
vault = Vault()
shield = Shield(entities=entities, use_ner=not args.no_ner, vault=vault)
masked = shield.mask(text)
print(masked) # Output masked text
if args.save_vault:
_save_vault(vault, args.save_vault) # Save mappings for later unmaskunmask command (Lines 111-114):
def _cmd_unmask(args):
text = _get_text(args)
vault = _load_vault(args.vault) # Load previously saved vault
print(vault.restore_text(text)) # Restore all tokensinspect command (Lines 117-127):
def _cmd_inspect(args):
text = _get_text(args)
detector = HybridDetector()
detections = detector.detect(text) # Detect but don't mask
for d in detections:
print(f" {d.entity_type} {d.value!r} (pos {d.start}-{d.end}, score={d.score:.2f})")Input flexibility (Lines 82-91):
def _get_text(args) -> str:
if args.file: # --file flag
return Path(args.file).read_text()
if args.text: # Positional argument
return args.text
if not sys.stdin.isatty(): # Piped input (echo "text" | shieldprompt mask)
return sys.stdin.read()
# None of the above — error
print("Error: provide text as argument, --file, or pipe via stdin", file=sys.stderr)
sys.exit(1)Purpose: An MCP (Model Context Protocol) server that lets Claude Code use ShieldPrompt as a tool. It speaks JSON-RPC 2.0 over stdin/stdout.
Session state (Lines 28-39):
_session_vault = Vault() # One vault per server session (persistent)
_session_shield: Shield | None = None # Lazily created
def _get_shield(entities=None):
global _session_shield
if _session_shield is None:
_session_shield = Shield(entities=entity_set, use_ner=False, vault=_session_vault)
return _session_shieldWhy persistent vault? The MCP server runs as a long-lived process. A user might mask in one call and unmask in a later call. The vault persists between calls.
Five tools exposed (Lines 63-137):
| Tool | What it does |
|---|---|
shield_mask |
Masks PII in text |
shield_unmask |
Restores tokens to real values |
shield_inspect |
Lists detected PII without masking |
shield_vault |
Shows current token-to-value mappings |
shield_clear |
Clears all mappings |
Main loop (Lines 213-244):
def main():
for line in sys.stdin: # Read JSON-RPC messages line by line
msg = json.loads(line)
method = msg.get("method")
msg_id = msg.get("id")
if msg_id is None: # Notification (no response needed)
handler = _NOTIFICATIONS.get(method)
if handler: handler(params)
continue
handler = _HANDLERS.get(method) # Request (needs response)
if handler:
response = handler(msg_id, params)
else:
response = _make_error(msg_id, -32601, f"Method not found: {method}")
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush() # Flush immediately for real-time communicationPurpose: Defines what from shieldprompt import ... gives you.
from .decorator import mask_pii # The @mask_pii decorator
from .entities import EntityType # The PII type enum
from .shield import Shield # The core engine
from .vault import Vault, get_vault # Token storage
__version__ = "0.1.0"
__all__ = ["Shield", "Vault", "EntityType", "mask_pii", "get_vault", "__version__"]Purpose: Enables running as python -m shieldprompt.
from .cli import main
main() # Just calls the CLI entry pointfrom shieldprompt import Shield
shield = Shield()
# MASK
text = "Contact alice@example.com or call 555-123-4567"
masked = shield.mask(text)
# masked = "Contact [EMAIL_ADDRESS_1] or call [PHONE_NUMBER_1]"
# ... send masked text to LLM ...
# LLM responds: "I'll reach out to [EMAIL_ADDRESS_1]"
# UNMASK
response = shield.unmask("I'll reach out to [EMAIL_ADDRESS_1]")
# response = "I'll reach out to alice@example.com"
@mask_pii(entities=["PERSON", "EMAIL_ADDRESS"])
def ask_llm(prompt: str) -> str:
# prompt is ALREADY masked when this runs
# e.g., "Schedule meeting with [PERSON_1] at [EMAIL_ADDRESS_1]"
return openai.chat(prompt)
# Return value is AUTOMATICALLY unmasked before caller gets it
# User just calls normally:
result = ask_llm("Schedule meeting with Alice at alice@example.com")
# result contains real names and emails, even though the LLM never saw them
# Mask and save vault
$ shieldprompt mask "SSN: 123-45-6789" --save-vault vault.json
SSN: [SSN_1]
# Later, unmask using saved vault
$ shieldprompt unmask "[SSN_1]" --vault vault.json
123-45-6789
# Inspect (detect without masking)
$ shieldprompt inspect "Email alice@example.com at Google"
EMAIL_ADDRESS 'alice@example.com' (pos 6-23, score=1.00)
ORGANIZATION 'Google' (pos 27-33, score=0.95)Shield picks Regex or Hybrid detector based on what entity types you request. You don't need to know which engine is used.
contextvars.ContextVar ensures each async task/thread gets its own vault. No PII leaks between concurrent requests.
Same PII value always produces the same token. "alice@example.com" is always [EMAIL_ADDRESS_1], even if it appears 10 times.
NER dependencies missing? Falls back to regex-only. No crashes, no configuration needed.
Detections are sorted by position descending before replacement, preventing index corruption.
Tokens are sorted by length descending during restoration, preventing [PERSON_10] from being partially corrupted by [PERSON_1] replacement.
The NER model (~400MB) is only loaded when detect() is first called, not at import time.
The middleware handles any JSON structure depth — nested objects, arrays, mixed types.