SurrogateShield intercepts every message before it reaches an LLM API, replaces personal information with realistic fake values, sends the sanitised payload, and restores your originals in the response. All cryptographic operations run locally. Ships as both an interactive dashboard and a self-contained pip install surrogateshield library.
Every message travels through detection, surrogate generation, encrypted mapping, the LLM call, and reconstruction. PII never crosses the API boundary in plaintext.
Light-touch path for queries that only work with location context (e.g. restaurants near…). Fuzzes the house number by ±2–8 instead of full anonymisation. Sensitive-topic keywords (medical, legal, shelter, immigration) force the full cascade.
PatternScan claims structured PII (regex + validators). EntityTrace (spaCy NER) catches names, places, orgs. ContextGuard (distilbert-NER) verifies borderline candidates. Each stage masks what it claims so downstream stages don’t double-detect.
Generates type-consistent surrogates with Faker — valid SSN formats, Luhn-passing credit cards, real-looking names. No collisions within a session.
Each {surrogate → original} pair is encrypted with AES-256-GCM using a per-conversation key derived via HKDF-SHA256. Stored on disk; unreadable without the device secret.
Receives surrogates only over HTTPS. The provider never sees the originals — not in the prompt, not in the conversation history, not in any RAG context.
Three-tier substitution on the response: exact match → component match (handles partial name mentions) → fuzzy match (rapidfuzz score ≥ 85). Restores originals before the user sees anything.
±2–8 (max ~100 m geographic error) and keeps the city — full anonymisation would destroy the answer. Sensitive-topic keywords (medical, legal, shelter, immigration) override the lighter touch and force full anonymisation.
Stages run sequentially. Spans claimed by an earlier stage are not re-processed downstream, so structured PII is masked before any NER model can hallucinate around it.
Regex-based structural detection with format validators. Runs first so structured PII is masked before any NER model sees it. Luhn for credit cards, ABA checksum for routing numbers, context-gated keywords for driver's licenses.
Named-entity recognition for unstructured PII — people, places, organisations, facilities. Returns two confidence tiers; borderline candidates are forwarded to ContextGuard. Includes an ORG→GPE reclassifier for location prepositions.
Transformer-based verifier (~250 MB, cached locally after first run). Confirms borderline candidates from EntityTrace and independently catches anything missed. Word-piece artefact cleaning and short-token blocklist prevent the most common NER false positives.
| Pass | Behaviour |
|---|---|
| A | Structural ORG — regex for [the/a/an] <name> [corporation|company|corp|inc|ltd|llc…]. No name lists. |
| B | Email-username reclassification — corrects ORG → PERSON when the entity text is a prefix of a detected email username. |
| C | PERSON component dedup — removes standalone surnames that are sub-components of already-detected full names. |
| D | Topical geo-entity filter — drops a GPE/LOC only if it appears exclusively in knowledge-query sub-clauses. |
Surfaces combinations of non-PII attributes that statistically re-identify an individual. Warnings fire before the message is sent.
| Combination | Risk | Source |
|---|---|---|
| ZIP + DOB + Gender | high | 87% of US population uniquely identifiable — Sweeney 2000 |
| Postcode + DOB | high | UK-locale analogue |
| Name + Employer + Location | medium | Professional cross-reference |
| IP + Location | medium | Network-level triangulation |
Pattern order matters. Patterns claim character spans; crypto and us_bank_number run before zip_us so 9-digit routing numbers and long hex strings are claimed before the ZIP pattern can fragment them.
| Type | Examples | Validator | Source |
|---|---|---|---|
| street address | 99 Cathedral Close · 456 Innovation Plaza | — | regex |
| ssn | 544-87-2944 | format | regex |
| user@example.com | RFC structure | regex | |
| phone_us | +1-480-555-1234 | — | regex |
| phone_uk | +44 7911 123456 | — | regex |
| phone_intl | +49 8234 927461 | — | regex |
| credit_card | 4111 1111 1111 1111 | Luhn algorithm | regex |
| dob | 01/15/1990 · March 14 1990 | — | regex |
| ipv4 | 192.168.1.100 | octet bounds | regex |
| api_key | sk-ant-… · ghp_… · AKIA… · AIzaSy… | prefix match | regex |
| gender_indicator | she/her · I am a man | phrase | regex |
| postcode_uk | SW1A 1AA | format | regex |
| zip_us | 85281 · 85281-1234 | format | regex |
| crypto new | BTC P2PKH / P2SH / Bech32 · ETH 0x… | charset | regex |
| us_bank_number new | 021000021 · 122105155 | ABA 9-digit checksum | regex |
| us_driver_license new | B7654321 · F123456789012 | context-gated | regex |
| PERSON | Sherwin Jathanna | model conf. | NER |
| GPE / LOC | geo-political / general location | model conf. | NER |
| ORG | organisation name | model conf. | NER |
| FAC | facility name | model conf. | NER |
| implicit location | contextual inference | cascade | inferred |
(3·d₀ + 7·d₁ + d₂ + 3·d₃ + 7·d₄ + d₅ + 3·d₆ + 7·d₇ + d₈) mod 10 = 0 — random 9-digit numbers almost always fail this. Driver's license is context-gated; the regex requires a keyword (driver's license, license number, DL) in the same phrase. Only the captured license value is marked as PII — the keyword prefix remains in the sanitised text.
Every surrogate is type-consistent and unique within a session. Fake names look like names. Fake SSNs pass format checks. Fake Bitcoin addresses are valid Base58. The LLM sees a coherent message — and produces a coherent response.
Persisted to conversations/<conv_id>.shadowmap as nonce (12 bytes) ‖ AES-GCM ciphertext. Unreadable without the device key.
| Entity type | Generated surrogate looks like |
|---|---|
| PERSON | Sarah Mitchell |
| jdoe@example.net | |
| ssn | XXX-XX-XXXX — valid format |
| phone_us | +1-###-###-#### |
| address | 789 Crescent Row, Springfield, IL |
| credit_card | valid Luhn-format number |
| dob | MM/DD/YYYY — age 18–80 |
| ip_address | 10.x.x.x |
| api_key | sk- + 32 random chars |
| crypto new | Bitcoin P2PKH — 1 + Base58 chars, 26–35 chars |
| us_bank_number new | valid 9-digit ABA routing number (passes checksum) |
| us_driver_license new | CA format — B4923817 |
Microsoft Presidio replaces PII with placeholder tokens. SurrogateShield replaces with type-consistent fakes. Per Table 7 (simulated attacker, per-value recovery): SurrogateShield 0.00% recovery on 196 targeted values, Presidio 1.53% on 196 — and BERTScore utility is ~95% vs ~84%.
| Type | SurrogateShield source | Presidio source |
|---|---|---|
| PERSON | EntityTrace / ContextGuard | Presidio NER |
| PatternScan | Presidio regex | |
| phone | PatternScan | Presidio regex |
| ssn | PatternScan | Presidio regex |
| credit_card | PatternScan (Luhn) | Presidio regex (Luhn) |
| ip_address | PatternScan | Presidio regex |
| dob | PatternScan | Presidio DATE_TIME |
| GPE | EntityTrace | Presidio LOCATION |
| crypto new | PatternScan | Presidio CRYPTO |
| us_bank_number new | PatternScan (ABA) | Presidio US_BANK_NUMBER |
| us_driver_license new | PatternScan (context-gated) | Presidio US_DRIVER_LICENSE |
| Type | Notes |
|---|---|
| api_key | SK / Bearer / GHP / AKIA / AIzaSy prefixes |
| address | Structural street-address regex |
| postal_code | US ZIP + UK postcode |
| gender_indicator | Explicit gender declarations |
| ORG | Organisation names (NER) |
| FAC | Facility names (NER) |
Pair a question file with a ground-truth answer key. The evaluator scores detection quality, per-type breakdown, ResolvePass leak rate, BERTScore utility preservation, and a per-stage contribution ablation.
Measured with roberta-large. Higher F1 means anonymisation preserved more of the original message's semantic meaning — i.e. the LLM still has the structure it needs to produce a useful response.
Four pipeline configurations are simulated post-hoc from the existing answers file. Each entity's source field tells us which stage detected it.
| Configuration | Detected set | Best for | Approx. F1 |
|---|---|---|---|
| PatternScan only | pattern_scan_pii | ssn · email · phone · CC · crypto · DL | 0.62 |
| PatternScan + EntityTrace | ∪ entity_trace_pii | + PERSON · GPE · ORG (high-conf) | 0.84 ↑ |
| PatternScan + ContextGuard | ∪ context_guard_pii | + borderline NER recovery | 0.80 ↑ |
| Full cascade | confirmed_pii | all types · best precision/recall balance | 0.91 ↑ |
ssn and email; EntityTrace for PERSON, GPE, ORG).
attacker.py simulates an adversary who intercepts the sanitised API payload and is given an adversarial prompt disclosing every PII type that was replaced. They are instructed to use linguistic analysis, demographic inference, cross-field correlation, and format patterns to recover the originals. Both anonymisation systems are attacked.
The attacker sees a coherent sanitised message: real-looking names, formatted SSNs, valid Luhn credit cards, plausible addresses.
The attacker sees [PERSON], [US_SSN], [ORGANIZATION] tokens scattered through the message.
Per-value rate: fraction of individual PII values recovered across all targeted values. Nᵥ = values targeted.
| Condition | Nᵥ | Recovered | Per-value rate |
|---|---|---|---|
| Presidio (placeholder redaction) | 196 | 3 | 1.53% |
| SurrogateShield (surrogates) | 196 | 0 | 0.00% |
| Metric | Description |
|---|---|
| questions_available | Questions with SS / Presidio data to attack |
| total_targeted | PII values the attacker attempted to recover |
| total_recovered | Attacker's guess matched the original (exact, lowercased) |
| recovery_rate | recovered / targeted — lower is better for privacy |
| recovery_rate_excl_address | Excludes service-query fuzzed addresses (proximity tracked separately) |
| by_type | Per-PII-type targeted / recovered counts |
The device secret never leaves the machine. Each conversation derives an independent key. ShadowMap files are unreadable without the device key and degrade gracefully on corruption.
~/.surrogateshield/device.key0o600 (owner read/write only)nonce (12 B) ‖ AES-GCM ciphertextconversations/<conv_id>.shadowmap*.shadowmap — encrypted surrogate storesconversations/*.json — chat historydevice.key — device secret.env — API keys
Local RAG via ChromaDB + sentence-transformers (all-MiniLM-L6-v2). No server. Real PII never enters the vector store.
document.txt
│
▼
SentinelLayer ▸ detect PII
│
▼
MimicGen ▸ generate surrogates
│
▼
rag_global ShadowMap
{ surrogate → original } persisted
│
▼
Anonymised document
│
▼
chunk @ RAG_CHUNK_SIZE = 512
│
▼
sentence-transformers
all-MiniLM-L6-v2
│
▼
ChromaDB ▸ ./chroma_db (persistent)
user query
│
▼
SentinelLayer ▸ anonymise query
│
▼
embed ▸ ChromaDB ▸ retrieve top-3
│
▼
Prepend anonymised context
│
▼
LLM API ← surrogates everywhere
│
▼
ResolvePass
restores user PII + indexed-doc PII
via rag_global ShadowMap
│
▼
Display to user
All thresholds, model identifiers, and toggles live in one file. Detection, generation, storage, reconstruction, and chat are independent modules with narrow interfaces.
SurrogateShield/ ├── main.py # CLI entry + interactive dashboard ├── pipeline.py # End-to-end message pipeline orchestration ├── config.py # Single source of truth — thresholds & flags ├── util.py # DetectedEntity, Conversation, logging ├── settings_manager.py # ~/.surrogateshield/settings.json ├── evaluator.py # P/R/F1 + Presidio + BERTScore + ablation ├── json_tester.py # Batch JSON question processing ├── attacker.py # Adversarial PII recovery experiment ├── run.sh # venv activation + .env loading │ ├── detection/ # SentinelLayer — three-stage cascade │ ├── logic.py # Cascade + post-processing passes A–D │ ├── pattern_scan.py # PatternScan — regex + validators │ ├── entity_trace.py # EntityTrace — spaCy en_core_web_lg │ ├── context_guard.py # ContextGuard — distilbert-NER │ ├── service_query.py # Address fuzzing for location queries │ ├── quasi_identifier.py # Sweeney k-anonymity risk scorer │ └── geo_data.py # Pass-through whitelist (US states, countries) │ ├── generation/logic.py # MimicGen — Faker, no collisions ├── storage/logic.py # ShadowMap — AES-256-GCM ├── reconstruction/logic.py # ResolvePass — exact · component · fuzzy │ ├── presidio/ # Comparison feature (optional) │ ├── engine.py # Lazy singleton AnalyzerEngine │ ├── detect.py # detect(text) → list[PresidioEntity] │ └── redact.py # redact(text, entities) → [TYPE] string │ ├── chatbot/ │ ├── chat.py # Claude / Gemini / OpenAI / Ollama │ └── rag.py # RAGStore — Chroma + sentence-transformers │ ├── experiment/ # Batch test files ├── tests/ # test1–test7 — no API key needed │ ├── python-library/ # Standalone pip package — surrogateshield │ ├── pyproject.toml # Package metadata + dependencies │ ├── README.md # Full library API reference │ └── surrogateshield/ # Self-contained — zero imports from main app │ ├── __init__.py # Public API: config · scan · mask · unmask · flush │ ├── _response_parser.py # Anthropic / OpenAI / Gemini / plain string │ └── core/ # detection · generation · storage · reconstruction │ └── conversations/ # Runtime — .json (surrogate text) + .shadowmap
You must activate a virtual environment before installing. Installing into the system or base conda Python is the most common cause of "package not found" errors at runtime.
# Clone $ git clone https://github.com/sherwinvishesh/SurrogateShield.git $ cd SurrogateShield # Create and activate a virtual environment ← required $ python -m venv .venv $ source .venv/bin/activate # Windows: .venv\Scripts\activate # Install dependencies $ pip install -r requirements.txt # Download the spaCy model (required by EntityTrace) $ python -m spacy download en_core_web_lg # ContextGuard's distilbert-NER (~250 MB) is downloaded # automatically from HuggingFace on first use. # Set your API key $ echo "ANTHROPIC_API_KEY=sk-ant-..." > .env # Launch $ ./run.sh
pip install surrogateshield gives you the full detection and masking engine as a five-function library with no dashboard dependency. See Library.
| Provider | Model | Env var |
|---|---|---|
| Claude default | claude-sonnet-4-6 | ANTHROPIC_API_KEY |
| Gemini | gemini-1.5-flash | GEMINI_API_KEY |
| ChatGPT | gpt-4o-mini | OPENAI_API_KEY |
| Local (Ollama) | llama3.2 (configurable) | none — offline |
# Interactive dashboard (recommended) $ python main.py $ ./run.sh # Start a new conversation directly $ python main.py chat $ python main.py chat --load <conversation-id> $ python main.py chat --rag # PII detection sandbox — no API calls, no credits $ python main.py pii-finder # Index a document into the RAG store $ python main.py add-doc path/to/document.txt
No dashboard, no CLI — just the core detection and masking pipeline as a clean Python API. The package is self-contained: it imports nothing from the main application and carries its own copies of every detection, generation, storage, and reconstruction module. The public surface is five functions and a single import line.
# Core package — spaCy model installs automatically $ pip install surrogateshield # Optional: Rich colour tables for detected PII / surrogates $ pip install "surrogateshield[display]"
en_core_web_lg) and the ContextGuard transformer (dslim/distilbert-NER, ~250 MB) install / download automatically — spaCy with the package, distilbert on the first mask() call, cached thereafter.
| Function | Description |
|---|---|
| shield.config(**kwargs) | Set thresholds, storage mode, pii_off list, service-query toggle, and display options |
| shield.scan(text) | Detect PII and return {value: type} — no substitution, no shadow-map update (alias: shield.pii_finder) |
| shield.mask(text) | Detect all PII, replace with surrogates, store the mapping — returns the sanitised string |
| shield.unmask(response) | Restore originals in an LLM response — accepts any provider object or a plain string |
| shield.flush() | Clear the session shadow map and generate a new session ID |
import anthropic import surrogateshield as shield client = anthropic.Anthropic() shield.config(detailed_view=False) # silent in production text = ( "Hi, I'm Sarah Mitchell. My email is sarah.mitchell@gmail.com, " "my SSN is 123-45-6789, and I was born on 04/12/1990." ) # Detect every PII field, swap in realistic fakes, store the mapping sanitized = shield.mask(text) # → "Hi, I'm Rachel Torres. My email is torresrachel@yahoo.com, # my SSN is 876-32-1045, and I was born on 09/27/1983." response = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, messages=[{"role": "user", "content": sanitized}], ) # unmask() takes the native SDK object directly — restores real values print(shield.unmask(response)) # contains "Sarah Mitchell", real SSN, … shield.flush() # end the session
The model never processes a single real value — yet the answer your users see contains their own data. Same flow works with OpenAI, Gemini, and local / Ollama models; unmask() auto-detects the response format.
Runs the full cascade and returns a {value: type} dict. Never modifies the text or the session — safe to call read-only.
pii_offpii_finder alias for data-pipeline readabilityshield.scan("") at startupPass a list of types or aliases to detect-but-not-replace — useful when a location app needs real city names but must still protect identities.
shield.config(pii_off=["location", "org"])phone · name · location · bank · crypto …"email", "ssn") accepted tooDefault is in-memory. Point pii_mem at a directory and the session map persists across processes, AES-256-GCM encrypted — for web servers and multi-worker deployments.
shield.config(pii_mem="/var/app/shadowmaps")0o600 permissionsflush() deletes the .shadowmap + .key filesunmask() tries Anthropic, OpenAI, and Gemini response formats automatically before falling back to str(response) — so plain strings from local models work too.
Message · OpenAI ChatCompletion · Gemini responseKeep two histories: one in surrogates for the API, one with real values for display. The session shadow map stays alive across turns, so a surrogate minted in turn 1 still resolves in turn 5 — the model never sees a real value in any turn.
from openai import OpenAI import surrogateshield as shield client = OpenAI() api_history, display_history = [], [] # surrogates vs real values def chat(user_input): sanitized = shield.mask(user_input) api_history.append({"role": "user", "content": sanitized}) display_history.append({"role": "user", "content": user_input}) response = client.chat.completions.create( model="gpt-4o-mini", messages=api_history, ) raw = response.choices[0].message.content # surrogate text restored = shield.unmask(response) # real values api_history.append({"role": "assistant", "content": raw}) display_history.append({"role": "assistant", "content": restored}) return restored chat("My name is John Doe and I live at 42 Baker Street, London.") print(chat("What was the address I mentioned earlier?")) # → "42 Baker Street, London" restored from turn 1's shadow map shield.flush()
# Google Gemini — unmask() reads response.text automatically model = genai.GenerativeModel("gemini-1.5-flash") sanitized = shield.mask("My card is 4532015112830366, IP 192.168.1.100.") print(shield.unmask(model.generate_content(sanitized))) shield.flush() # Local / Ollama — unmask() also accepts a plain string sanitized = shield.mask("My phone is +44 7911 123456, postcode SW1A 1AA.") raw_reply = ask_ollama(sanitized) # your own HTTP call print(shield.unmask(raw_reply)) shield.flush()
Each pass only runs on surrogates the previous one did not resolve, so partial matches never corrupt unrelated text.
| Pass | Method | Handles |
|---|---|---|
| 1 | exact replacement | Surrogate appears verbatim in the response — the vast majority of cases |
| 2 | component matching | Multi-word surrogate used partially (model wrote "Rachel" from "Rachel Torres") — scoped to unresolved surrogates only |
| 3 | fuzzy · rapidfuzz | partial_ratio ≥ fuzzy_threshold (default 85) — reformatted casing, added punctuation, minor edits |
| Parameter | Default | Effect |
|---|---|---|
| detailed_view | True | Print Rich tables after each call; set False for silent / production operation |
| pii_mem | "temp" | "temp" = in-memory; a directory path = AES-256-GCM disk persistence (must exist) |
| pii_off | None | Types detected but not replaced — aliases or raw type strings |
| service | True | Minimal address fuzzing for location queries instead of full replacement |
| spacy_model | en_core_web_lg | EntityTrace NER model — swap for en_core_web_sm for speed over accuracy |
| context_guard_enabled | True | Run the distilbert second pass — set False for faster, spaCy-only detection |
| entity_trace_high_threshold | 0.85 | spaCy score at/above which an entity is confirmed immediately |
| entity_trace_low_threshold | 0.60 | spaCy score at/above which an entity is forwarded to ContextGuard |
| context_guard_threshold | 0.70 | distilbert score required to promote a borderline / new entity |
| entity_trace_fallback_threshold | 0.65 | Used only when ContextGuard is disabled, to promote borderline entities |
| fuzzy_threshold | 85 | rapidfuzz partial_ratio (0–100) for pass 3 of unmask() — below 70 not recommended |
| phone | phone_us · phone_uk · phone_intl |
| postal_code | zip_us · postcode_uk |
| name / names | PERSON |
| location | GPE · LOC |
| org · facility | ORG · FAC |
| bank · license · crypto | us_bank_number · us_driver_license · crypto |
A location question like "find a coffee shop near 99 Market Street" would be useless with a random fake address. With service=True (default) the library shifts the house number by ±1 (~20 m) and preserves the street, city, and state verbatim.
shield.config(service=False) to always fully anonymiseWhen a combination is detected, all constituent fields are added to the replace set — not just the obviously-sensitive ones.
| Combination | Risk | Basis |
|---|---|---|
| ZIP + DOB + Gender | high | 87% of US population uniquely identified — Sweeney 2000 |
| Postcode + DOB | high | UK ICO guidance — sufficient for re-identification |
| Name + SSN | high | Enables direct identity theft |
| Name + DOB | high | Standard identity-verification combination worldwide |
| Phone + Name | high | Directly identifies an individual |
| IP Address + Name | high | Enables device-level identification |
| Name + Employer + City | medium | Uniquely identifies in most cities |
| Email + Location | medium | Narrows to a specific named individual |
| Phone + Location | medium | Narrows to a local individual |
| DOB + Location + Employer | medium | Highly specific triple |
| faker | Realistic fake values per type |
| cryptography | AES-256-GCM for the disk shadow map |
| rapidfuzz | Fuzzy reconstruction pass |
| spacy + en-core-web-lg | Stage 2 NER (model bundled) |
| transformers + torch | Stage 3 ContextGuard |
| requests | Optional Nominatim address verify |
shield.scan("") at startupconfig(context_guard_enabled=False) skips distilbert (spaCy-only, lower recall)fuzzy_threshold toward 75 to recover heavily reformatted valuesconfig(detailed_view=False) silences all tables~/.cache/huggingface/tests/test7.py covers the standalone package end to end — library entities, ShadowMap memory + persistent modes, response parser, pii_off filtering, threshold wiring, ResolvePass, and all five public functions.
SurrogateShield is described in our paper on arXiv. Pick a format and copy — BibTeX is the default.