◆  ────────────────────────────────────  ◆

      S   U   R   R   O   G   A   T   E

      ███████╗██╗  ██╗██╗███████╗██╗     ██████╗ 
      ██╔════╝██║  ██║██║██╔════╝██║     ██╔══██╗
      ███████╗███████║██║█████╗  ██║     ██║  ██║
      ╚════██║██╔══██║██║██╔══╝  ██║     ██║  ██║
      ███████║██║  ██║██║███████╗███████╗██████╔╝
      ╚══════╝╚═╝  ╚═╝╚═╝╚══════╝╚══════╝╚═════╝ 

      ◆  ────────────────────────────────────  ◆
v / 1.4.0 build / 2026.05 python / ≥ 3.9 pip / surrogateshield license / Apache-2.0 status / ● operational

PII never leaves
your device. Surrogates do.

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.

3-stage
Detection cascade
regex → spaCy → distilbert
AES-256-GCM
ShadowMap encryption
HKDF-SHA256 per conv
0.00%
Adversarial PII
recovery rate
~95%
BERTScore utility
preservation
01 / PIPELINE
How it works

A single round-trip through the proxy, end to end.

Every message travels through detection, surrogate generation, encrypted mapping, the LLM call, and reconstruction. PII never crosses the API boundary in plaintext.

pipeline.live — watch a single message travel through the proxy 01 DETECT 02 SURROGATE 03 SEND 04 RECEIVE 05 RESOLVE
Detect
SentinelLayer scans for PII spans
USER ▸ on device
Hi, I'm Sherwin. My email is sjathann@asu.edu and my SSN is 544-87-2944.
surrogates only · HTTPS
LLM API ▸ remote
waiting…
response · contains surrogates
USER ▸ display
awaiting response…
SHADOWMAP AES-256-GCM
PERSON Sherwin Sarah Mitchell
email sjathann@asu.edu jdoe@example.net
ssn 544-87-2944 317-42-8806
pipeline.flow — visual annotated
user message [ SERVICE QUERY DETECTOR ] route: fuzz · pass · full-cascade SENTINELLAYER three-stage detection cascade PatternScan regex stage 1 EntityTrace spaCy NER stage 2 ContextGuard distilbert-NER stage 3 MimicGen · surrogate generation Faker · type-consistent · no collisions SHADOWMAP · AES-256-GCM { surrogate → original } HKDF-SHA256 · per-conversation key LLM API ↳ surrogates only · never real values RESOLVEPASS exact · component · fuzzy (≥ 85) restored output
pipeline.flow — explained read me
01
Service-query router

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.

02
SentinelLayer 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.

03
MimicGen

Generates type-consistent surrogates with Faker — valid SSN formats, Luhn-passing credit cards, real-looking names. No collisions within a session.

04
ShadowMap

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.

05
LLM API

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.

06
ResolvePass

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.

Service-query intelligence. A message like "restaurants near 1126 E Apache Blvd, Tempe, AZ" shifts the house number by ±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.
02 / DETECTION
SentinelLayer

Three detectors. Each masks what it claims.

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.

STAGE 01

PatternScan

detection/pattern_scan.py

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.

methodregex + validators
coversSSN · email · phone · CC · DOB · IP · API keys · crypto · ABA · DL · ZIP
span policyclaim → block downstream
STAGE 02

EntityTrace

spaCy · en_core_web_lg

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.

confirmed≥ 0.85
borderline0.60 – 0.85
fallback0.65 (CG off)
STAGE 03

ContextGuard

dslim/distilbert-NER

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.

confidence≥ 0.70
size~250 MB cached
servernone — in-process

Post-processing passes detection/logic.py

PassBehaviour
AStructural ORG — regex for [the/a/an] <name> [corporation|company|corp|inc|ltd|llc…]. No name lists.
BEmail-username reclassification — corrects ORG → PERSON when the entity text is a prefix of a detected email username.
CPERSON component dedup — removes standalone surnames that are sub-components of already-detected full names.
DTopical geo-entity filter — drops a GPE/LOC only if it appears exclusively in knowledge-query sub-clauses.

Quasi-identifier scorer per Sweeney (2000) k-anonymity

Surfaces combinations of non-PII attributes that statistically re-identify an individual. Warnings fire before the message is sent.

CombinationRiskSource
ZIP + DOB + Genderhigh87% of US population uniquely identifiable — Sweeney 2000
Postcode + DOBhighUK-locale analogue
Name + Employer + LocationmediumProfessional cross-reference
IP + LocationmediumNetwork-level triangulation
03 / COVERAGE
PII types detected

Structured, named, and inferred — across twenty-plus categories.

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.

TypeExamplesValidatorSource
street address99 Cathedral Close · 456 Innovation Plazaregex
ssn544-87-2944formatregex
emailuser@example.comRFC structureregex
phone_us+1-480-555-1234regex
phone_uk+44 7911 123456regex
phone_intl+49 8234 927461regex
credit_card4111 1111 1111 1111Luhn algorithmregex
dob01/15/1990 · March 14 1990regex
ipv4192.168.1.100octet boundsregex
api_keysk-ant-… · ghp_… · AKIA… · AIzaSy…prefix matchregex
gender_indicatorshe/her · I am a manphraseregex
postcode_ukSW1A 1AAformatregex
zip_us85281 · 85281-1234formatregex
crypto newBTC P2PKH / P2SH / Bech32 · ETH 0x…charsetregex
us_bank_number new021000021 · 122105155ABA 9-digit checksumregex
us_driver_license newB7654321 · F123456789012context-gatedregex
PERSONSherwin Jathannamodel conf.NER
GPE / LOCgeo-political / general locationmodel conf.NER
ORGorganisation namemodel conf.NER
FACfacility namemodel conf.NER
implicit locationcontextual inferencecascadeinferred
ABA checksum. (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.
04 / GENERATION
MimicGen

Realistic surrogates, not [PLACEHOLDER] tokens.

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.

ORIGINAL · user input
Hi, I'm Sherwin Jathanna. My SSN is 544-87-2944 and I live at 99 Cathedral Close, Phoenix, AZ 85281. Reach me at sjathann@asu.edu or +1-480-555-1234. Can you draft a resignation letter to my employer Northstar Robotics?
SANITISED · sent to LLM
Hi, I'm Sarah Mitchell. My SSN is 317-42-8806 and I live at 789 Crescent Row, Springfield, IL 62704. Reach me at jdoe@example.net or +1-217-555-0142. Can you draft a resignation letter to my employer Halcyon Dynamics?

ShadowMap — encrypted surrogate ↔ original

Persisted to conversations/<conv_id>.shadowmap as nonce (12 bytes) ‖ AES-GCM ciphertext. Unreadable without the device key.

PERSON Sherwin Jathanna Sarah Mitchell
ssn 544-87-2944 317-42-8806
address 99 Cathedral Close, Phoenix, AZ 789 Crescent Row, Springfield, IL
email sjathann@asu.edu jdoe@example.net
phone_us +1-480-555-1234 +1-217-555-0142
ORG Northstar Robotics Halcyon Dynamics

Per-type surrogate templates

Entity typeGenerated surrogate looks like
PERSONSarah Mitchell
emailjdoe@example.net
ssnXXX-XX-XXXX  — valid format
phone_us+1-###-###-####
address789 Crescent Row, Springfield, IL
credit_cardvalid Luhn-format number
dobMM/DD/YYYY  — age 18–80
ip_address10.x.x.x
api_keysk- + 32 random chars
crypto newBitcoin P2PKH — 1 + Base58 chars, 26–35 chars
us_bank_number newvalid 9-digit ABA routing number (passes checksum)
us_driver_license newCA format — B4923817
05 / COMPARISON
Surrogates vs placeholders

Why realistic surrogates beat [ENTITY_TYPE] redaction.

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%.

SurrogateShield

Realistic surrogates
Hi, I'm Sarah Mitchell. My SSN is 317-42-8806. Can you draft a resignation letter to Halcyon Dynamics?
BERTScore F1~ 95%
Per-value recovery0.00%
Nᵥ targeted196
Recovered0

Microsoft Presidio

Placeholder redaction
Hi, I'm [PERSON]. My SSN is [US_SSN]. Can you draft a resignation letter to [ORGANIZATION]?
BERTScore F1~ 84%
Per-value recovery1.53%
Nᵥ targeted196
Recovered3

Comparable types · both systems detect

TypeSurrogateShield sourcePresidio source
PERSONEntityTrace / ContextGuardPresidio NER
emailPatternScanPresidio regex
phonePatternScanPresidio regex
ssnPatternScanPresidio regex
credit_cardPatternScan (Luhn)Presidio regex (Luhn)
ip_addressPatternScanPresidio regex
dobPatternScanPresidio DATE_TIME
GPEEntityTracePresidio LOCATION
crypto newPatternScanPresidio CRYPTO
us_bank_number newPatternScan (ABA)Presidio US_BANK_NUMBER
us_driver_license newPatternScan (context-gated)Presidio US_DRIVER_LICENSE

SS-only types · Presidio does not detect

TypeNotes
api_keySK / Bearer / GHP / AKIA / AIzaSy prefixes
addressStructural street-address regex
postal_codeUS ZIP + UK postcode
gender_indicatorExplicit gender declarations
ORGOrganisation names (NER)
FACFacility names (NER)
06 / EVALUATION
Batch evaluation

Precision, recall, F1 — and an ablation study.

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.

M01
P / R / F1
Overall detection
Surrogate detection precision, recall, F1, accuracy
M02
per-type
Per-entity breakdown
F1 / precision / recall for every PII type
M03
leak rate
ResolvePass
Fraction of responses where a surrogate was not restored
M04
sanitisation
Quality
Fraction of questions where real PII reached the LLM
M05
BERTScore
Utility preservation
Semantic distance vs original (roberta-large)
M06
ablation
Per-stage F1
Pattern · +EntityTrace · +ContextGuard · full cascade

BERTScore — utility preservation

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.

No anonymisation
100.0%
SurrogateShield
~95.0%
Presidio (placeholders)
~84.0%

Ablation — per-stage F1 contribution

Four pipeline configurations are simulated post-hoc from the existing answers file. Each entity's source field tells us which stage detected it.

ConfigurationDetected setBest forApprox. 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
Note. F1 figures above are representative ranges from the in-repo example set; actual values depend on the question file. The Evaluation screen reports exact values for your data plus the Key stage column — which configuration first achieves ≥80% F1 for each type (PatternScan for structured types like ssn and email; EntityTrace for PERSON, GPE, ORG).
07 / ADVERSARIAL
Attacker experiment

An informed adversary recovers nothing.

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.

VARIANT A

SurrogateShield — realistic fakes

The attacker sees a coherent sanitised message: real-looking names, formatted SSNs, valid Luhn credit cards, plausible addresses.

  • Nᵥ targeted: 196 PII values
  • Recovered: 0 — per-value rate 0.00%
  • Surrogates have no statistical relationship to originals
  • Tracked separately: address proximity recovery (service-query fuzzing)
VARIANT B

Presidio — placeholder redaction

The attacker sees [PERSON], [US_SSN], [ORGANIZATION] tokens scattered through the message.

  • Nᵥ targeted: 196 PII values
  • Recovered: 3 — per-value rate 1.53%
  • Placeholders leak type information, enabling occasional cross-field inference
  • Result: SurrogateShield is strictly more resistant
The research claim. Realistic surrogates beat placeholder redaction on both dimensions — lower per-value recovery (0.00% vs 1.53%) and substantially higher semantic utility (~95% vs ~84% BERTScore F1). Presidio’s placeholders leak type information, which the adversary occasionally exploits via cross-field inference; surrogates carry no such signal.

Table 7 — Simulated attacker per-value recovery rates

Per-value rate: fraction of individual PII values recovered across all targeted values. Nᵥ = values targeted.

ConditionNᵥRecoveredPer-value rate
Presidio (placeholder redaction) 196 3 1.53%
SurrogateShield (surrogates) 196 0 0.00%

Per-attempt analysis

MetricDescription
questions_availableQuestions with SS / Presidio data to attack
total_targetedPII values the attacker attempted to recover
total_recoveredAttacker's guess matched the original (exact, lowercased)
recovery_raterecovered / targeted — lower is better for privacy
recovery_rate_excl_addressExcludes service-query fuzzed addresses (proximity tracked separately)
by_typePer-PII-type targeted / recovered counts
08 / SECURITY
Cryptographic design

Local-only key material. AES-256-GCM on every write.

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.

KEY MATERIAL

Device secret + per-conversation derivation

  • 32-byte random key at ~/.surrogateshield/device.key
  • File permissions: 0o600 (owner read/write only)
  • Per-conversation key: HKDF-SHA256 with device secret as IKM and conversation ID as salt
  • Generated once on first launch; never transmitted
SHADOWMAP

Encrypted surrogate ↔ original store

  • AES-256-GCM with a fresh 12-byte nonce per save
  • Format: nonce (12 B) ‖ AES-GCM ciphertext
  • Location: conversations/<conv_id>.shadowmap
  • Missing / corrupt → empty mapping, no crash
TRANSMISSION

Wire-level guarantees

  • Only surrogates sent over HTTPS to the provider
  • Conversation history holds surrogate text, not originals
  • RAG documents anonymised through full SentinelLayer before indexing
  • Vector store operates exclusively on surrogates
.GITIGNORE

What is excluded by default

  • *.shadowmap — encrypted surrogate stores
  • conversations/*.json — chat history
  • device.key — device secret
  • .env — API keys
09 / RAG
Privacy-aware retrieval

Documents are anonymised at index time.

Local RAG via ChromaDB + sentence-transformers (all-MiniLM-L6-v2). No server. Real PII never enters the vector store.

rag.index — at index time chromadb in-process
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)
rag.query — at query time top-k = 3
user query
    │
    ▼
SentinelLayer  ▸ anonymise query
    │
    ▼
embed ▸ ChromaDB ▸ retrieve top-3
    │
    ▼
Prepend anonymised context
    │
    ▼
LLM APIsurrogates everywhere
    │
    ▼
ResolvePass
   restores user PII + indexed-doc PII
   via rag_global ShadowMap
    │
    ▼
Display to user
10 / ARCHITECTURE
Project structure

Single source of truth in config.py. Everything else is a module.

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
11 / INSTALL
Quick start

Up and running in under a minute.

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.

terminal · bash installation
# 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
Just want the pipeline in your own app? Skip the clone — pip install surrogateshield gives you the full detection and masking engine as a five-function library with no dashboard dependency. See Library.

Supported providers

ProviderModelEnv var
Claude defaultclaude-sonnet-4-6ANTHROPIC_API_KEY
Geminigemini-1.5-flashGEMINI_API_KEY
ChatGPTgpt-4o-miniOPENAI_API_KEY
Local (Ollama)llama3.2 (configurable)none — offline
12 / CLI
Dashboard reference

Press a key. Do a thing.

terminal · bashcommands
# 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

Dashboard shortcuts

NNew conversation
RRAG mode
PPII Finder — no API
JJSON Test batch
EEvaluation — P/R/F1
AAttacker experiment
SSettings — provider, view
HHelp
1–9Open saved conversation
D1–9Delete conversation
QQuit
13 / LIBRARY
Standalone package

The whole pipeline, in pip install surrogateshield.

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.

terminal · bashinstall
# Core package — spaCy model installs automatically
$ pip install surrogateshield

# Optional: Rich colour tables for detected PII / surrogates
$ pip install "surrogateshield[display]"
Zero manual model steps. Both the spaCy NER model (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.

Public API — five functions

FunctionDescription
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

Quick start — a full round-trip

python · claudemask ▸ call ▸ unmask ▸ flush
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.

INSPECT

scan() — detect without masking

Runs the full cascade and returns a {value: type} dict. Never modifies the text or the session — safe to call read-only.

  • Returns every entity regardless of pii_off
  • pii_finder alias for data-pipeline readability
  • Pre-warm models with shield.scan("") at startup
SELECTIVE

pii_off — detect but keep

Pass 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"])
  • Aliases: phone · name · location · bank · crypto
  • Raw type strings ("email", "ssn") accepted too
PERSISTENT

Encrypted disk shadow map

Default 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")
  • Per-session 32-byte key, 0o600 permissions
  • flush() deletes the .shadowmap + .key files
PORTABLE

Any provider, any object

unmask() tries Anthropic, OpenAI, and Gemini response formats automatically before falling back to str(response) — so plain strings from local models work too.

  • Anthropic Message · OpenAI ChatCompletion · Gemini response
  • Multi-turn: session map survives across turns
  • Same 20-type cascade as the full application

Multi-turn conversations

Keep 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.

python · openaitwo histories, one session
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()

One pattern, every provider

pythongemini · ollama / local
# 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()

unmask() — three reconstruction passes

Each pass only runs on surrogates the previous one did not resolve, so partial matches never corrupt unrelated text.

PassMethodHandles
1exact replacementSurrogate appears verbatim in the response — the vast majority of cases
2component matchingMulti-word surrogate used partially (model wrote "Rachel" from "Rachel Torres") — scoped to unresolved surrogates only
3fuzzy · rapidfuzzpartial_ratio ≥ fuzzy_threshold (default 85) — reformatted casing, added punctuation, minor edits

config() — full parameter reference

ParameterDefaultEffect
detailed_viewTruePrint 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_offNoneTypes detected but not replaced — aliases or raw type strings
serviceTrueMinimal address fuzzing for location queries instead of full replacement
spacy_modelen_core_web_lgEntityTrace NER model — swap for en_core_web_sm for speed over accuracy
context_guard_enabledTrueRun the distilbert second pass — set False for faster, spaCy-only detection
entity_trace_high_threshold0.85spaCy score at/above which an entity is confirmed immediately
entity_trace_low_threshold0.60spaCy score at/above which an entity is forwarded to ContextGuard
context_guard_threshold0.70distilbert score required to promote a borderline / new entity
entity_trace_fallback_threshold0.65Used only when ContextGuard is disabled, to promote borderline entities
fuzzy_threshold85rapidfuzz partial_ratio (0–100) for pass 3 of unmask() — below 70 not recommended
PII_OFF ALIASES

Short names, expanded

phonephone_us · phone_uk · phone_intl
postal_codezip_us · postcode_uk
name / namesPERSON
locationGPE · LOC
org · facilityORG · FAC
bank · license · cryptous_bank_number · us_driver_license · crypto
SERVICE QUERIES

Proportional address protection

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.

  • House number fuzzed, everything else kept
  • Sensitive topics (medical, legal, shelter, DV, rehab) force full replacement regardless
  • shield.config(service=False) to always fully anonymise

Quasi-identifier combination scoring Sweeney k-anonymity

When a combination is detected, all constituent fields are added to the replace set — not just the obviously-sensitive ones.

CombinationRiskBasis
ZIP + DOB + Genderhigh87% of US population uniquely identified — Sweeney 2000
Postcode + DOBhighUK ICO guidance — sufficient for re-identification
Name + SSNhighEnables direct identity theft
Name + DOBhighStandard identity-verification combination worldwide
Phone + NamehighDirectly identifies an individual
IP Address + NamehighEnables device-level identification
Name + Employer + CitymediumUniquely identifies in most cities
Email + LocationmediumNarrows to a specific named individual
Phone + LocationmediumNarrows to a local individual
DOB + Location + EmployermediumHighly specific triple
DEPENDENCIES

Installed automatically

fakerRealistic fake values per type
cryptographyAES-256-GCM for the disk shadow map
rapidfuzzFuzzy reconstruction pass
spacy + en-core-web-lgStage 2 NER (model bundled)
transformers + torchStage 3 ContextGuard
requestsOptional Nominatim address verify
TROUBLESHOOTING

Common adjustments

  • Slow first call? Models load lazily — pre-warm with shield.scan("") at startup
  • Need it faster? config(context_guard_enabled=False) skips distilbert (spaCy-only, lower recall)
  • Surrogate not restored? Lower fuzzy_threshold toward 75 to recover heavily reformatted values
  • Noisy stdout? config(detailed_view=False) silences all tables
  • distilbert (~250 MB) downloads once to ~/.cache/huggingface/
134 checks, no API key. 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.
14 / CITE
Citation

Cite this work.

SurrogateShield is described in our paper on arXiv. Pick a format and copy — BibTeX is the default.