SurrogateShield Documentation
SurrogateShield is a privacy-preserving proxy for LLMs. It intercepts every message before it reaches an LLM API, replaces personally identifiable information (PII) with realistic fake surrogates, sends the sanitised payload, and restores your originals in the response. All cryptographic operations run locally. Nothing sensitive is ever transmitted.
SurrogateShield ships in two forms:
- Interactive dashboard — a full terminal UI with chat, PII Finder, batch evaluation, and the Attacker Experiment
pip install surrogateshield— a standalone Python library with a five-function API you can embed in any application
Installation
Requirements
- Python 3.9 or newer
- pip
- A virtual environment (strongly recommended — see below)
Step-by-step
Clone the repository
git clone https://github.com/sherwinvishesh/SurrogateShield.git
cd SurrogateShield
Create and activate a virtual environment
# macOS / Linux
python -m venv .venv
source .venv/bin/activate
# Windows
python -m venv .venv
.venv\Scripts\activate
You should see (.venv) in your prompt after activation.
Install Python dependencies
pip install -r requirements.txt
This installs everything: the Anthropic client, spaCy, Faker, cryptography, sentence-transformers, and more. First install can take 2–5 minutes.
Download the spaCy model
python -m spacy download en_core_web_lg
Required by EntityTrace (stage 2 NER detection) and optionally by the Presidio comparison panel. This is a one-time download (~700 MB).
ContextGuard model (automatic)
The dslim/distilbert-NER model (~250 MB) is downloaded automatically from HuggingFace Hub on first run. No manual command needed. Subsequent runs are instant — it is cached locally.
Full dependency list
| Package | Version | Purpose |
|---|---|---|
| anthropic | ≥0.25.0 | Claude API client |
| python-dotenv | ≥1.0.0 | .env file loading |
| spacy | ≥3.7.0 | EntityTrace NER + Presidio NLP backend |
| faker | ≥24.0.0 | Surrogate generation |
| cryptography | ≥42.0.0 | AES-256-GCM, HKDF for ShadowMap |
| rapidfuzz | ≥3.6.0 | Fuzzy reconstruction matching in ResolvePass |
| typer | ≥0.12.0 | CLI framework |
| rich | ≥13.7.0 | Terminal UI |
| chromadb | ≥0.4.0 | RAG vector store (in-process) |
| sentence-transformers | ≥2.7.0 | RAG embeddings (all-MiniLM-L6-v2) |
| transformers | ≥4.40.0 | ContextGuard (distilbert-NER) |
| torch | ≥2.0.0 | ContextGuard inference |
| requests | ≥2.31.0 | Address verification via Nominatim |
| bert-score | ≥0.3.13 | Utility preservation scoring (optional) |
| ollama | ≥0.1.8 | Local LLM support (optional) |
| presidio-analyzer | ≥2.2.0 | Presidio comparison panel (optional) |
| presidio-anonymizer | ≥2.2.0 | Presidio anonymization (optional) |
Environment Variables
Create a .env file in the project root. You only need the key for the provider you intend to use:
# Required for Claude (default provider)
ANTHROPIC_API_KEY=sk-ant-...
# Required for Gemini
GEMINI_API_KEY=AIzaSy...
# Required for ChatGPT
OPENAI_API_KEY=sk-...
# No key needed for local Ollama
The .env file is listed in .gitignore — it will never be accidentally committed.
Quick Start
# 1. Activate your venv
source .venv/bin/activate
# 2. Add your API key
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
# 3. Launch the dashboard
./run.sh
# or: python main.py
The launcher script run.sh activates the venv and loads the .env file automatically.
First Run
On the first launch, ContextGuard downloads its model (~250 MB) from HuggingFace Hub. This may take 1–5 minutes depending on your connection. Progress is shown in the terminal. After that, the model is cached at ~/.cache/huggingface/ and loads instantly.
The interactive dashboard will appear. You will see:
- The SurrogateShield ASCII banner
- The pipeline overview (7 stages)
- A list of any saved conversations
- The action menu
Press N to start a new conversation. Type any message containing PII and watch the pipeline work.
Pipeline Overview
Every message you type goes through the following stages before it reaches the LLM. On the way back, surrogates in the response are replaced with your originals before you see anything.
ServiceQueryDetector
Not all location information is equally sensitive. A message like "restaurants near 1126 E Apache Blvd, Tempe, AZ" needs the city and state to give a useful answer — fully replacing them with fake values would destroy the response quality.
ServiceQueryDetector identifies these location queries and applies a proportional response:
- Street addresses: house number is shifted by ±2–8 (max ~100 m geographic error), street name and city are preserved
- City/state names: not replaced — they provide no meaningful re-identification risk in service queries
- Sensitive topic override: if the message contains keywords like medical, legal, shelter, or immigration, full anonymisation is forced regardless of query structure
Address existence is optionally verified via OpenStreetMap Nominatim (disable with SERVICE_QUERY_VERIFY_ADDRESSES = False in config.py for offline use).
SentinelLayer — The Detection Cascade
Three detectors run sequentially. Spans claimed by an earlier stage are not re-processed downstream, preventing double-detection. Each stage is described in detail below.
PatternScan — Stage 1
Regex-based structural detection. Runs first so all structured PII is masked before any NER model sees it. Format validators (Luhn, ABA checksum) reject false positives before any entity is emitted.
| Pattern | Example | Validator |
|---|---|---|
| Street address | 99 Cathedral Close, 456 Innovation Plaza | — |
| SSN | 544-87-2944 | — |
| user@example.com | — | |
| Phone US | +1-480-555-1234 | — |
| Phone UK | +44 7911 123456 | — |
| Phone international | +49 8234 927461 | — |
| Credit card | 4111 1111 1111 1111 | Luhn algorithm |
| Date of birth | 01/15/1990, March 14 1990 | — |
| IPv4 | 192.168.1.100 | — |
| API key / secret | sk-ant-..., Bearer ..., ghp_..., AKIA..., AIzaSy... | — |
| Gender indicator | gender: female, she/her, I am a man | — |
| UK postcode | SW1A 1AA | — |
| US ZIP code | 85281, 85281-1234 | — |
| Crypto wallet new | Bitcoin P2PKH/P2SH/Bech32, Ethereum 0x... | — |
| ABA routing number new | 021000021, 122105155 | ABA 9-digit checksum |
| US driver's license new | B7654321, F123456789012 | Context-gated (keyword required) |
crypto and us_bank_number run before zip_us so that 9-digit routing numbers and long hex strings are claimed before the ZIP pattern can fragment them.
ABA routing number checksum
The ABA 9-digit checksum eliminates false positives — random 9-digit numbers almost always fail:
(3·d₀ + 7·d₁ + d₂ + 3·d₃ + 7·d₄ + d₅ + 3·d₆ + 7·d₇ + d₈) mod 10 = 0
Driver's license context gate
The driver's license regex requires a keyword (driver's license, license number, DL, D.L.) within the same phrase. Only the license value itself is marked as an entity — the keyword prefix stays in the sanitised text.
EntityTrace — Stage 2
spaCy en_core_web_lg Named Entity Recognition. Extracts PERSON, GPE, LOC, ORG, and FAC entities. Returns two confidence tiers:
| Tier | Score range | Action |
|---|---|---|
| Confirmed | ≥ 0.85 | Promoted immediately to the entity list |
| Borderline | 0.60 – 0.85 | Forwarded to ContextGuard for verification |
| Fallback (CG off) | ≥ 0.65 | Promoted when ContextGuard is disabled |
EntityTrace also includes an ORG→GPE reclassifier: when a location preposition appears before an entity name (e.g. "lives in Google"), the classification is corrected from ORG to GPE.
ContextGuard — Stage 3
Transformer-based verifier using dslim/distilbert-NER (~250 MB, downloaded once from HuggingFace Hub on first run, no server required). Two jobs:
- Confirm borderline candidates forwarded from EntityTrace (requires score ≥ 0.70)
- Independently detect anything EntityTrace missed
Applies word-piece artefact cleaning and a blocklist of short/title tokens that commonly cause false positives (e.g. "I", "A", "The").
ContextGuard can be disabled via config.py or via the library's shield.config(context_guard_enabled=False) for faster, spaCy-only detection.
Post-processing Passes
Four additional passes run on the combined entity set from all three stages:
| Pass | What it does |
|---|---|
| A — Structural ORG | Regex for [the/a/an] <name> [corporation|company|corp|inc|ltd|llc…] — catches corporate names without relying on NER name lists |
| B — Email-username reclassification | Corrects ORG→PERSON when the entity text is a prefix of a detected email username (e.g. "alice" from alice@corp.com) |
| C — PERSON component dedup | Removes standalone surnames that are sub-components of already-detected full names (prevents "Mitchell" being a second entity when "Sarah Mitchell" is already detected) |
| D — Topical geo-entity filter | Drops a GPE/LOC only if it appears exclusively in knowledge-query sub-clauses (e.g. "What is the capital of France?" — "France" is topical, not PII) |
MimicGen — Surrogate Generation
Generates type-consistent surrogates using Faker. Each surrogate is realistic for its PII type and unique within the session (tracked via a used_surrogates set to guarantee no collisions).
| Entity type | Generated surrogate looks like |
|---|---|
| PERSON | Sarah Mitchell |
| jdoe@example.net | |
| ssn | XXX-XX-XXXX (valid format) |
| phone_us | +1-###-###-#### |
| phone_uk | +44 7### ###### |
| phone_intl | +49 8234 927461 |
| 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 |
| zip_us / postcode_uk | Correct format |
| api_key | sk- + 32 random chars |
| GPE / LOC / ORG / FAC | Faker city/company names |
| gender_indicator | Grammatically valid gender expression |
| crypto new | Bitcoin P2PKH format — 1 + Base58 chars, 26–35 chars |
| us_bank_number new | Valid 9-digit ABA routing number (passes checksum) |
| us_driver_license new | CA-format: letter + 7 digits (e.g. B4923817) |
Example: before and after substitution
ShadowMap — Encrypted Storage
An encrypted, per-conversation mapping of {surrogate → original}. Stored on disk; unreadable without the device secret.
| Property | Detail |
|---|---|
| Encryption | AES-256-GCM with a fresh 12-byte nonce per save |
| Key derivation | HKDF-SHA256 with device secret as IKM and conversation ID as salt |
| Device secret | Generated once at ~/.surrogateshield/device.key with 0o600 permissions |
| File location | conversations/<conv_id>.shadowmap (binary, not human-readable) |
| Format | nonce (12 bytes) ‖ AES-GCM ciphertext |
| Graceful degradation | Missing or corrupt file → empty mapping, no crash |
ResolvePass — Response Restoration
Three-pass restoration of original values in LLM responses:
Exact replacement
Handles the vast majority of cases. The surrogate string in the response is replaced with the original value verbatim.
Component matching
For multi-word surrogates (e.g. the LLM mentions only "Mitchell" when the surrogate was "Sarah Mitchell"), scoped to unresolved surrogates only to prevent corruption of adjacent text.
Fuzzy matching
rapidfuzz partial_ratio with a configurable threshold (default 85). Handles typos and paraphrasing by the LLM. Every failure is categorised as exact_miss, fuzzy_hit, or fuzzy_miss for research analysis.
CLI Commands
# Launch the interactive dashboard (recommended)
python main.py
./run.sh
# Start a new chat conversation directly
python main.py chat
# Continue a saved conversation
python main.py chat --load <conversation-id>
# New conversation with RAG enabled
python main.py chat --rag
# List all saved conversations
python main.py list
# PII detection sandbox — no API calls, no credits
python main.py pii-finder
# Index a document into the RAG vector store
python main.py add-doc path/to/document.txt
Dashboard Keyboard Shortcuts
These shortcuts work from the main dashboard screen.
| Key | Action |
|---|---|
| N | New conversation |
| R | New conversation with RAG mode enabled |
| P | PII Finder — test detection without any API call |
| 1 – 9 | Open saved conversation by number |
| D1 – D9 | Delete conversation by number (e.g. D3 deletes conversation #3) |
| J | JSON Test — batch-process a question file |
| E | Evaluation — score pipeline quality against ground-truth |
| A | Attacker Experiment — simulate adversarial PII recovery |
| S | Settings (provider selection, view mode, Presidio comparison) |
| H | Help screen |
| Q | Quit |
Chat Mode
Chat mode is the primary way to use SurrogateShield. Press N from the dashboard or run python main.py chat.
In each turn:
- Type your message and press Enter
- The full detection cascade runs on your message
- Detected PII is replaced with surrogates
- The sanitised message is sent to the LLM API
- The response is returned and surrogates are restored
- You see the response with your real values
If Detailed View is enabled (Settings → D), each turn also shows:
- Pipeline stage logs
- A per-entity PII detection table
- The API transparency panel (what was sent, what was received, the final restored output)
Type exit or press Ctrl+C to return to the dashboard. Conversations are saved automatically.
PII Finder
The PII Finder is a detection sandbox with zero API calls and zero credits spent. Press P from the dashboard or run python main.py pii-finder.
Type any text to see exactly what SurrogateShield would detect and what it would send to the LLM. The service-query path (address fuzzing) is also active, so you can test how location queries are handled.
Special commands in PII Finder:
reset— clears the surrogate memory so you can start freshexit— returns to the dashboard
If the Presidio comparison panel is enabled in Settings, the PII Finder also shows a side-by-side comparison with Microsoft Presidio's [ENTITY_TYPE] placeholder redaction below each result.
RAG Mode
RAG (Retrieval-Augmented Generation) lets you index your own documents and have them automatically retrieved as context for your questions. Backed by ChromaDB and all-MiniLM-L6-v2 embeddings — no server required, runs in-process.
Indexing a document
python main.py add-doc path/to/your/document.txt
Documents are anonymised through the full SentinelLayer pipeline before indexing. Real PII never enters the vector store. Surrogate mappings from indexed documents are stored in a shared rag_global ShadowMap.
Starting a RAG conversation
Press R from the dashboard, or run python main.py chat --rag. Retrieved context is prepended to your sanitised message before the LLM call.
Configuration
| Setting in config.py | Default | Effect |
|---|---|---|
| RAG_TOP_K | 3 | Number of document chunks retrieved per query |
| RAG_CHUNK_SIZE | 512 | Characters per chunk when splitting indexed documents |
| EMBEDDING_MODEL | all-MiniLM-L6-v2 | sentence-transformers model for embeddings |
JSON Batch Test
Batch-process a list of questions through the full pipeline. Press J from the dashboard.
Input format
Create a JSON file in the experiment/ directory:
[
{ "input": "My name is Sherwin and my SSN is 544-87-2944. What are Wyoming's tax benefits?" },
{ "input": "My email is sjathann@asu.edu and phone is 480-555-1234. Draft a resignation letter." }
]
Running
- Press J in the dashboard
- Enter the filename (e.g.
testforexperiment/test.json) - Select which output fields to capture (toggle with number keys)
- Press Enter to run
Output is saved to experiment/<name>_answers.json. Results are flushed every 25 questions — safe to interrupt and resume.
Available output fields
| Field | What it captures |
|---|---|
| question | The original question text |
| pattern_scan_pii | Entities detected by PatternScan (regex stage) |
| entity_trace_pii | Entities detected by EntityTrace (spaCy NER) |
| context_guard_pii | Entities detected by ContextGuard (distilbert-NER) |
| confirmed_pii | Final combined confirmed entity list |
| pii_detail | Per-entity type, score, and source |
| quasi_id_risks | Quasi-identifier combination risks detected |
| surrogate_map | Mapping of original → surrogate for every replaced entity |
| sanitized_input | The exact text sent to the LLM |
| llm_response | Raw LLM response (surrogates, before restoration) |
| stage_timings_ms | PatternScan / EntityTrace / ContextGuard / surrogate gen / LLM latency in ms |
| presidio_sanitized_input | Presidio [TYPE]-placeholder redaction (baseline for BERTScore) |
| presidio_found_piis | Presidio raw detected entities — type, value, score |
| bertscore_ss | BERTScore of original vs SurrogateShield sanitised input |
| bertscore_presidio | BERTScore of original vs Presidio sanitised input |
presidio_sanitized_input, presidio_found_piis, bertscore_ss, and bertscore_presidio are off by default. Enable them in the field selection screen to generate data for the Presidio comparison and BERTScore tables in Evaluation.
Evaluation Mode
Score a completed answers file against a ground-truth key. Press E from the dashboard.
Ground-truth key format
[
{
"Question": "My name is Sherwin and my SSN is 544-87-2944...",
"Answer-Key": {
"name": "Sherwin",
"ssn": "544-87-2944"
}
}
]
Save this as experiment/<name>_key.json. The evaluator accepts flexible label names — see the supported labels below.
Supported answer-key labels
| Label(s) in key file | Internal type |
|---|---|
| name, person, PERSON | PERSON |
| phone, phone_us, phone_uk, phone_intl | phone |
| ssn | ssn |
| address | address |
| dob, date_of_birth | dob |
| org, ORG, organization | ORG |
| gpe, GPE, location | GPE |
| credit_card | credit_card |
| api_key | api_key |
| ip, ip_address | ip_address |
| zip, zip_us, postcode, postcode_uk | postal_code |
| gender | gender_indicator |
| fac, FAC | FAC |
| crypto, bitcoin, ethereum, wallet new | crypto |
| bank_number, us_bank_number, routing_number, routing new | us_bank_number |
| driver_license, us_driver_license, dl, license new | us_driver_license |
Metrics reported
| Metric | Description |
|---|---|
| Precision / Recall / F1 / Accuracy | Overall surrogate detection quality |
| Error (miss rate) | Fraction of key PII values not detected |
| Stage timings | Average ms per pipeline stage |
| ResolvePass leak rate | Fraction of responses where a surrogate was not restored |
| Sanitisation quality | Fraction of questions where real PII reached the LLM |
| Per-entity-type breakdown | F1 / precision / recall per PII type |
| Presidio comparison (Table 1) | SS vs Presidio side-by-side per comparable type + overall |
| BERTScore comparison (Table 2) | Semantic utility preservation: SS vs Presidio vs no-anonymisation baseline |
| Ablation study (Table 4) | Per-stage F1 contribution across four pipeline configurations |
Ablation study
The ablation study quantifies how much each detection stage contributes to overall F1. It is computed post-hoc from the existing answers file — no pipeline re-run needed. Each answer records which stage detected each entity via the source field in pii_detail.
| Configuration | Detected set |
|---|---|
| PatternScan only | pattern_scan_pii |
| PatternScan + EntityTrace | pattern_scan_pii ∪ entity_trace_pii |
| PatternScan + ContextGuard | pattern_scan_pii ∪ context_guard_pii |
| Full cascade (all three) | confirmed_pii |
To generate ablation data, enable pattern_scan_pii, entity_trace_pii, context_guard_pii, and confirmed_pii in JSON Test before running (all four are on by default).
Attacker Experiment
Simulates an informed adversary who intercepts the sanitised text SurrogateShield sends to the LLM API and actively tries to recover the original PII values. Press A from the dashboard.
What it tests
Two variants run on each question from an existing answers file:
| Variant | What the attacker sees | Goal |
|---|---|---|
| SurrogateShield | Sanitised text with realistic fake values (fake names, SSNs, emails) | Try to recover the originals from the surrogates |
| Presidio | Redacted text with [ENTITY_TYPE] placeholder tokens | Try to recover the originals from the placeholders |
Expected result
0% recovery for both systems. Surrogates have no cryptographic or statistical relationship to the original values. This proves SurrogateShield achieves equivalent inference resistance to blunt placeholder redaction, while preserving significantly higher semantic utility (as measured by BERTScore).
Running the experiment
Press A in the dashboard. The four-screen flow will:
- Explain the experiment
- Prompt for an existing answers file from
experiment/(must containsurrogate_map,sanitized_input, and optionallypresidio_sanitized_input/presidio_found_piis) - Show question counts and estimated API calls, then run
- Display a results summary on completion
The experiment supports resume — if interrupted, re-running with the same answers file picks up where it left off.
Output files
| File | Contents |
|---|---|
| <stem>_Attacker_Experiment.json | Per-question recovery attempt details for both variants |
| <stem>_Attacker_Experiment_Analysis.json | Aggregated recovery rates, per-type breakdown, overall assessment |
Required fields in answers file
| Field | Required for |
|---|---|
| surrogate_map | SS attacker — original PII values and their surrogates |
| sanitized_input | SS attacker — the text to attack |
| pii_detail | SS attacker — type metadata for the prompt |
| presidio_sanitized_input | Presidio attacker — the placeholder-redacted text |
| presidio_found_piis | Presidio attacker — type metadata for the prompt |
PII Types Detected
SurrogateShield detects 20 PII types across three detection mechanisms:
| Category | Types |
|---|---|
| Structural (regex) PatternScan | SSN, email, phone (US/UK/international), credit card, street address, DOB, IPv4, API keys/secrets, gender indicator, US ZIP, UK postcode, crypto wallet (Bitcoin/Ethereum), ABA routing number, US driver's license |
| Named entities (NER) EntityTrace / ContextGuard | PERSON, GPE (geo-political entity), LOC, ORG, FAC (facility) |
| Inferred | Implicit location from context |
PII Examples
Structured PII (PatternScan)
# SSN
544-87-2944 → 317-42-8806
# Email
sjathann@asu.edu → jdoe@example.net
# Phone US
+1-480-555-1234 → +1-602-555-9871
# Credit card (Luhn-valid)
4111 1111 1111 1111 → 5234 5678 9012 3456
# Bitcoin wallet
1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf → 1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2
# ABA routing number
021000021 → 071000013
# Driver's license (context-gated)
My DL number is B7654321 → My DL number is F2390481
Named entities (EntityTrace + ContextGuard)
# Person
Sherwin → Sarah Mitchell
# City / GPE
Phoenix, AZ → Springfield, IL (in sensitive contexts)
# Note: well-known cities are often preserved in service queries
# Organisation
Arizona State University → Lakewood Industries
# Facility
Banner Health Hospital → Riverside Medical Center
Quasi-Identifier Risk
Based on Sweeney's k-anonymity research. Even without traditional PII, certain combinations of fields can uniquely re-identify individuals. SurrogateShield detects these and warns you before the message is sent.
| Combination | Risk level | Notes |
|---|---|---|
| ZIP + DOB + Gender | High | 87% of US population uniquely identifiable (Sweeney 2000) |
| Postcode + DOB | High | High re-identification risk even without a name |
| Name + Employer + Location | Medium | Combination enables targeted lookup |
| IP + Location | Medium | Together they narrow to a specific user |
Warnings appear in the terminal when these combinations are detected. Enable Detailed View (Settings → D) to always show quasi-identifier analysis.
Runtime Settings
Changed interactively from inside the app (press S → Settings). Persisted across sessions in ~/.surrogateshield/settings.json.
| Key | Default | What it controls |
|---|---|---|
| llm_provider | claude | Active LLM backend — Claude / Gemini / ChatGPT / Local |
| detailed_view | false | Show pipeline stage logs, per-entity PII table, and the API transparency panel in each chat turn |
| presidio_comparison | false | Show the Presidio side-by-side panel below each PII Finder result. Off by default — requires presidio packages and spaCy model |
Advanced Configuration (config.py)
Hard-coded thresholds and flags. Edit the file directly to change them. No restart required for PII Finder; restart required for chat sessions.
| Setting | Default | Description |
|---|---|---|
| ENTITY_TRACE_HIGH_THRESHOLD | 0.85 | spaCy score above which an entity is immediately confirmed |
| ENTITY_TRACE_LOW_THRESHOLD | 0.60 | spaCy score above which an entity is forwarded to ContextGuard |
| CONTEXT_GUARD_CONFIDENCE_THRESHOLD | 0.70 | distilbert score required to confirm a borderline entity |
| ENTITY_TRACE_FALLBACK_THRESHOLD | 0.65 | Score used when ContextGuard is disabled to promote borderline entities |
| FUZZY_MATCH_THRESHOLD | 85 | rapidfuzz partial_ratio threshold for ResolvePass reconstruction |
| SERVICE_QUERY_DETECTION_ENABLED | True | Enable the lightweight address-fuzzing path for location queries |
| SERVICE_QUERY_VERIFY_ADDRESSES | True | Verify fuzzed addresses via OpenStreetMap Nominatim (disable for offline use) |
| SHOW_API_TRANSPARENCY | True | Show the sent / received / restored transparency panel after each chat turn |
| RAG_TOP_K | 3 | Number of document chunks retrieved per RAG query |
| RAG_CHUNK_SIZE | 512 | Characters per chunk when splitting indexed documents |
| CLAUDE_MODEL | claude-sonnet-4-6 | Claude model identifier |
| SPACY_MODEL | en_core_web_lg | spaCy model used by EntityTrace and Presidio |
| EMBEDDING_MODEL | all-MiniLM-L6-v2 | sentence-transformers model for RAG embeddings |
LLM Providers
Switch providers from the Settings menu (press S inside the dashboard).
| Provider | Model | Env var required |
|---|---|---|
| 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 — runs fully offline |
Enabling Presidio comparison
The Presidio comparison panel is off by default. To enable it:
- Activate your venv, then install:
pip install presidio-analyzer presidio-anonymizer - Download the spaCy model (if not already done):
python -m spacy download en_core_web_lg - Open the app and press S → C (Settings → Presidio Comparison) to toggle it on
Python Library — Installation
SurrogateShield is also available as a standalone pip package. No dashboard, no CLI — just the core detection and masking pipeline as a clean Python API.
pip install surrogateshield
Both the spaCy model (en_core_web_lg) and the ContextGuard transformer model (dslim/distilbert-NER) install automatically — no separate download step needed.
Python Library — Quick Start
import surrogateshield as shield
# Silence the default Rich tables (recommended for production)
shield.config(detailed_view=False)
user_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."
)
sanitized = shield.mask(user_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 = any_llm.chat(sanitized) # send surrogates, not real PII
restored = shield.unmask(response) # real values restored from the session map
shield.flush() # clear session before the next conversation
Python Library — Public API
| Function | Description |
|---|---|
| shield.config(**kwargs) | Set thresholds, storage mode, pii_off list, and display options |
| shield.mask(text) | Detect all PII, replace with surrogates, store the mapping. Returns the sanitised string. |
| shield.unmask(response) | Restore original PII in an LLM response (any provider object or plain string) |
| shield.scan(text) | Detect PII and return {value: type} dict — no substitution, no shadow map update |
| shield.pii_finder | Alias for shield.scan |
| shield.flush() | Clear the session shadow map and generate a new session ID |
scan() — Inspect Without Masking
scan() runs the full detection cascade and returns a {value: type} dict, but it never modifies the session or updates the ShadowMap. Safe to call in a read-only context.
found = shield.scan(
"Contact Alice Nguyen at alice@corp.com or call +1-415-555-0198."
)
# {"Alice Nguyen": "PERSON", "alice@corp.com": "email", "+1-415-555-0198": "phone_us"}
for value, pii_type in found.items():
print(f"{pii_type:15s} {value}")
scan() always returns every detected entity regardless of pii_off settings.
pii_off — Suppress Specific Types
Sometimes you want to detect certain PII types but not replace them. Use pii_off to suppress substitution for specific types while still detecting them.
shield.config(pii_off=["location", "org"])
sanitized = shield.mask(
"Emma Johnson works at Deloitte in New York, email emma@deloitte.com."
)
# "Emma Johnson" → replaced (PERSON not in pii_off)
# "emma@deloitte.com" → replaced (email not in pii_off)
# "Deloitte" → kept (org in pii_off)
# "New York" → kept (location in pii_off)
Available aliases for pii_off
phone, name, location, org, email, ssn, dob, address, zip, postcode, postal_code, credit_card, ip_address, api_key, crypto, bank, license, gender_indicator
Raw type strings ("PERSON", "GPE", etc.) also accepted.
Persistent ShadowMap
By default the session mapping is in-memory only. For web servers or multi-process deployments, point pii_mem at a directory and the map is encrypted to disk with AES-256-GCM.
import os, surrogateshield as shield
os.makedirs("/var/app/shadowmaps", exist_ok=True)
shield.config(pii_mem="/var/app/shadowmaps")
sanitized = shield.mask("My name is Clara Oswald, phone 555-123-4567.")
restored = shield.unmask(llm.chat(sanitized))
shield.flush() # deletes the .shadowmap and .key files for this session
When pii_mem is a directory path, each session gets its own .shadowmap file encrypted with AES-256-GCM. shield.flush() deletes the files and generates a new session ID.
Provider Examples
unmask() accepts native SDK response objects directly — no manual text extraction needed.
Anthropic / Claude
import anthropic
import surrogateshield as shield
client = anthropic.Anthropic()
shield.config(detailed_view=False)
sanitized = shield.mask(user_text)
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": sanitized}]
)
print(shield.unmask(response)) # passes Anthropic Message object directly
shield.flush()
OpenAI / ChatGPT
from openai import OpenAI
import surrogateshield as shield
client = OpenAI()
shield.config(detailed_view=False)
sanitized = shield.mask(user_text)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": sanitized}]
)
print(shield.unmask(response)) # passes ChatCompletion object directly
shield.flush()
Google Gemini
import google.generativeai as genai
import surrogateshield as shield
model = genai.GenerativeModel("gemini-1.5-flash")
shield.config(detailed_view=False)
sanitized = shield.mask(user_text)
response = model.generate_content(sanitized)
print(shield.unmask(response)) # passes GenerateContentResponse directly
shield.flush()
Ollama / plain string
import surrogateshield as shield
shield.config(detailed_view=False)
sanitized = shield.mask(user_text)
raw_reply = ask_ollama(sanitized) # plain string
print(shield.unmask(raw_reply)) # plain strings work too
shield.flush()
Library Config Parameters
| Parameter | Default | Effect |
|---|---|---|
| detailed_view | True | Print Rich tables after each call; set False in production |
| pii_mem | "temp" | "temp" for in-memory; a directory path for AES-256-GCM disk persistence |
| pii_off | [] | PII types to detect but not replace |
| service | True | Minimal address fuzzing for location queries instead of full replacement |
| context_guard_enabled | True | Run the HuggingFace second-pass NER; set False for faster, spaCy-only detection |
| fuzzy_threshold | 85 | rapidfuzz score (0–100) used in the third reconstruction pass of unmask() |
| spacy_model | en_core_web_lg | spaCy model for EntityTrace; swap for en_core_web_sm for faster but lower-accuracy NER |
Security Design
| Component | Mechanism |
|---|---|
| Device secret | 32-byte random key at ~/.surrogateshield/device.key, permissions 0o600 |
| Per-conversation key | HKDF-SHA256 with device secret as IKM and conversation ID as salt |
| ShadowMap encryption | AES-256-GCM with fresh 12-byte nonce per write |
| ShadowMap format | nonce (12 bytes) ‖ AES-GCM ciphertext — unreadable without device key |
| API transmission | Only surrogates sent — real values never leave the device |
| Conversation history | Stored locally in conversations/; JSON holds surrogate text, not originals |
| .gitignore | *.shadowmap, conversations/*.json, device.key, .env all excluded |
Privacy Guarantees
- No PII crosses the API boundary. Every entity confirmed by SentinelLayer is replaced before the HTTP request is made.
- Service queries get proportional protection. A restaurant search near your home address gets the house number shifted; the city name is preserved so the answer is useful. Sensitive topic overrides (medical, legal, shelter) force full anonymisation regardless.
- Geographic generality is preserved. US states, countries, and major cities are never replaced — they provide no meaningful re-identification risk and destroying them would break answer quality.
- Quasi-identifier risks are surfaced. If your message contains combinations like ZIP+DOB+gender that are statistically re-identifying even without traditional PII, you are warned before the message is sent.
- RAG documents are anonymised at index time. Real PII never enters the vector store. Retrieval and context injection all operate on surrogates.
- Financial and identity credentials are protected. Bitcoin/Ethereum wallet addresses, ABA routing numbers, and driver's license numbers are detected and replaced alongside traditional PII.
Project Structure
SurrogateShield/
├── main.py # CLI entry point and interactive dashboard
├── pipeline.py # End-to-end message pipeline orchestration
├── config.py # All constants and thresholds (single source of truth)
├── util.py # Shared dataclasses (DetectedEntity, Conversation)
├── settings_manager.py # Persistent user settings (~/.surrogateshield/settings.json)
├── evaluator.py # Precision/recall/F1 evaluation logic + Presidio/BERTScore/ablation
├── json_tester.py # Batch JSON question processing
├── attacker.py # Adversarial PII recovery experiment
├── run.sh # Launcher script (venv activation, .env loading)
│
├── detection/ # SentinelLayer — three-stage PII detection cascade
│ ├── logic.py # Cascade orchestration + post-processing passes A–D
│ ├── pattern_scan.py # PatternScan — regex-based structured PII detection
│ ├── entity_trace.py # EntityTrace — spaCy NER (en_core_web_lg)
│ ├── context_guard.py # ContextGuard — distilbert-NER (dslim/distilbert-NER)
│ ├── service_query.py # ServiceQueryDetector — address fuzzing for location queries
│ ├── quasi_identifier.py # Quasi-identifier combination risk scorer (k-anonymity)
│ └── geo_data.py # Geographic pass-through whitelist (US states, countries)
│
├── generation/
│ └── logic.py # MimicGen — type-consistent surrogate generation (Faker)
│
├── storage/
│ └── logic.py # ShadowMap — AES-256-GCM encrypted surrogate mapping store
│
├── reconstruction/
│ └── logic.py # ResolvePass — three-pass surrogate→original restoration
│
├── presidio/ # Presidio integration layer (optional comparison feature)
│ ├── engine.py # Lazy singleton AnalyzerEngine wrapper
│ ├── detect.py # detect(text) → list[PresidioEntity]
│ └── redact.py # redact(text, entities) → [TYPE]-placeholder string
│
├── chatbot/
│ ├── chat.py # Multi-provider LLM conversation handler
│ └── rag.py # RAGStore — ChromaDB + sentence-transformers vector store
│
├── experiment/ # Batch test input/output files
│ ├── example.json # Sample question file
│ ├── example_key.json # Sample ground-truth answer key
│ └── example_answers.json # Sample output from JSON Test
│
├── tests/ # Test suite (no API key required)
│ ├── test1.py # PatternScan, cascade, MimicGen, ShadowMap, ResolvePass
│ ├── test2.py # Additional detection and generation tests
│ ├── test3.py # Additional pipeline and storage tests
│ ├── test6.py # Attacker Experiment — all mocked, no API key needed
│ └── test7.py # Python library — 134 checks, no API key required
│
└── python-library/ # Standalone pip package (surrogateshield)
├── pyproject.toml
└── surrogateshield/ # Public API: config, scan, mask, unmask, flush
Running Tests
# Activate your venv first
source .venv/bin/activate
# Core pipeline tests (no API key required)
python tests/test1.py
python tests/test2.py
python tests/test3.py
# Attacker Experiment tests (all API calls mocked, no API key required)
python tests/test6.py
# Python library tests (run from repo root)
python tests/test7.py
| Test file | What it covers |
|---|---|
| test1.py | PatternScan, EntityTrace, SentinelLayer cascade, MimicGen, ShadowMap, ResolvePass, and a full no-API pipeline round-trip |
| test2.py | Additional detection and generation tests |
| test3.py | Additional pipeline and storage tests |
| test6.py | Attacker Experiment module — type formatting, recovery scoring, API response parsing, analysis aggregation, and end-to-end experiment flow (all mocked) |
| test7.py | Standalone surrogateshield pip package — entities, ShadowMap memory and persistent modes, response parser, state singletons, pipeline pii_off filtering, threshold wiring, ResolvePass, and all five public API functions. 134 checks, no API key required. |
Troubleshooting
Presidio shows "not installed"
This almost always means the packages were installed into a different Python than the one running the app. Fix:
# Activate your venv first, then:
pip install presidio-analyzer presidio-anonymizer
python -m spacy download en_core_web_lg
"Package not found" at runtime
You ran pip install without activating the venv first. The packages went into the system Python. Fix:
source .venv/bin/activate # activate venv
pip install -r requirements.txt # reinstall into venv
ContextGuard model download is slow
The dslim/distilbert-NER model is ~250 MB. On a slow connection, the first download can take 5–15 minutes. Progress is shown in the terminal. Once downloaded, it is cached at ~/.cache/huggingface/.
BERTScore takes very long
BERTScore uses roberta-large (~1.4 GB). On CPU, scoring a batch of 50 questions can take 15–30 minutes. The model is only downloaded if you enable BERTScore fields in JSON Test. If you have a GPU, it will be used automatically.
Address verification fails in offline environments
ServiceQueryDetector optionally verifies fuzzed addresses via OpenStreetMap Nominatim. In offline environments, disable this:
# In config.py
SERVICE_QUERY_VERIFY_ADDRESSES = False
Conversations are not found after moving the project
Conversations are stored in conversations/ relative to the project root. If you move the project directory, the existing .shadowmap files are no longer decryptable with the device key at ~/.surrogateshield/device.key — but the device key itself stays in place, so new conversations in the new location will work normally.
spaCy model not found
# With venv active:
python -m spacy download en_core_web_lg
SurrogateShield v1.4.0 · Apache-2.0 · Back to home