Admin Assistant โ PII Redaction Design โ
Status: Implemented โ Layer 1 (client block-and-warn) and Layer 2 (server-side sanitizer) both shipped in the privacy hardening roll-up (PR #544). Pattern catalog centralized in @lantern/shared/privacy. Tracking: Privacy hardening master tracker #523. Action plan row: P3 / HIGH / "Anthropic admin assistant PII redaction." Companion docs: PRIVACY_HARDENING_ROADMAP.md, SEALED_IDENTITY.md, ../superpowers/plans/2026-05-11-privacy-hardening-action-plan.md. Author: privacy workstream. Opened: 2026-05-26.
1. Why this doc exists โ
The admin portal includes an AI chat assistant (services/api/assistant/) โ agentic Claude with tool access to docs, OpenAPI specs, and Firestore reads. The 2026-05-11 audit flagged that admins can paste user PII (phone numbers, emails) directly into the chat, and that PII goes to Anthropic with zero redaction.
This undermines the sealed-identity architecture: we spent ten PRs ensuring Lantern itself can't relink a UID to a phone, then leak the link through the admin chat. The fix is two-layer redaction: block-and-warn at the UI, and a sanitizer at the request-to-Anthropic boundary as defense in depth.
2. Threat model โ
What we're protecting:
| Surface | Without redaction | With this design |
|---|---|---|
| Anthropic API request logs | Carry user phone/email/JWT in prompts | Carry pseudonyms only (<phone>, <email>, <jwt>) |
| Anthropic conversation history (if persisted by them) | Same as above | Same as above |
| Subpoena targeting Anthropic | Anthropic can produce PII from their logs | Anthropic produces only scrubbed prompts |
| Misconfigured opt-in to training | User PII could enter training corpus | Scrubbed prompts only |
| Operational accident (admin pastes a wall of records) | 100 users' PII to Anthropic in one click | Block-and-warn fires before send |
What stays visible to Anthropic by design:
| Surface | Visibility | Why we accept it |
|---|---|---|
| Firebase UIDs | Yes (28-char opaque strings) | UIDs are pseudonyms; meaningless outside Lantern's infra. Admin needs some identifier to work with. See ยง8 of this doc. |
| Lantern pseudonyms (lanternName) | Yes | Same โ these are app-internal display names, not real identity |
| Admin question semantics | Yes | The whole point of the assistant is processing the admin's question |
3. Architecture โ two-layer redaction โ
Layer 1: Block-and-warn UX (client-side, primary line of defense) โ
Runs in the admin portal chat UI before the request leaves the browser. Detects PII shapes in the admin's typed message and blocks the send by default, presenting a modal with two choices.
Layer 2: Request-layer sanitizer (server-side, defense in depth) โ
Runs in assistant-api immediately before the call to api.anthropic.com. Scrubs PII shapes from the entire request payload (admin message + tool result content + system prompt). Catches anything that slips past the UI โ most importantly, PII embedded in tool results that the admin didn't type.
Two layers, two failure modes:
- Layer 1 fails (e.g., XSS bypass, custom client) โ Layer 2 catches it
- Layer 2 fails (regex miss on a novel pattern) โ Layer 1 catches admin-typed input
Each layer scrubs the same patterns (ยง4) and emits the same observability events (ยง7).
4. Scrub patterns โ
The shapes we replace, and what we replace them with:
| Pattern | Regex | Placeholder | Notes |
|---|---|---|---|
| Phone (E.164) | \+\d{6,} | <phone> | Anchored on + to avoid mangling firestore IDs / timestamps. Deliberately no upper digit cap โ a \d{6,15}\b bound silently failed to match (and so leaked) any 16+ digit run, since \b can't anchor between two digits. |
[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,} | <email> | Requires a real alphabetic TLD so it doesn't grab host-like tokens (fn@http) or absorb trailing punctuation/labels. | |
| JWT | eyJ[A-Za-z0-9_-]{30,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+ | <jwt> | Three base64url segments |
| Credit card | \b\d(?:[ -]?\d){12,18}\b + Luhn check | <card> | 13โ19 digits with optional single separators; structured to avoid backtracking ambiguity. Regex alone is too noisy, so a match only scrubs if it passes the Luhn checksum. |
Explicitly NOT scrubbed:
| Pattern | Why kept |
|---|---|
| Firebase UIDs (28-char alphanumeric) | UID is the recommended way for admins to refer to users. Scrubbing would defeat the purpose. See ยง8. |
| Lantern names | App-internal pseudonyms; not PII |
| Timestamps, hashes, file paths | Operationally useful; not identifying |
Source of truth: the pattern catalog lives in packages/shared/privacy/index.js (SCRUB_PATTERNS, scrubPiiFromString, detectPiiCategories) and is consumed by both the client (admin chat block-and-warn modal + apps/web/src/lib/flash.js) and the assistant-api server sanitizer. Add or change a pattern in that one module and every consumer โ and this table โ should be updated together.
5. Block-and-warn UX (Layer 1) โ
Trigger: admin clicks Send (or hits Enter); client-side scan runs on the typed message.
On hit:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PII detected โ
โ โ
โ Your message contains: โ
โ โข phone number โ
โ โข email address โ
โ โ
โ The admin assistant doesn't accept user PII. โ
โ Use the user's UID instead โ it's the safe way โ
โ to refer to a user. โ
โ โ
โ [ Edit message ] [ Send scrubbed ] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโBehavior:
- Default action ("Edit message") cancels the send, returns to the input with the original text intact.
- Escape hatch ("Send scrubbed") sends with PII replaced by placeholders.
- Both buttons require a deliberate click โ no muscle-memory
enter to senddefeating the protection. - The modal lists exactly which patterns were detected (phone, email, etc.) so the admin knows what to revise.
- If the admin clicks "Send scrubbed", an observability event fires (ยง7) and the request proceeds.
Out of scope for v1: "remember my choice" / "always send scrubbed" toggle. Every PII detection requires a deliberate decision.
6. Request-layer sanitizer (Layer 2) โ
Runs in services/api/assistant/src/ at the boundary just before calling Anthropic's API. The sanitizer:
- Walks the entire request payload: system prompt, all messages in the conversation history, all tool results inline.
- Applies the ยง4 scrub patterns to every string value.
- If anything was scrubbed, appends a system-prompt annotation:
Note: PII patterns were redacted from this conversation. Placeholder tokens (<phone>, <email>, etc.) refer to scrubbed values. - Emits an observability event (ยง7).
- Calls Anthropic with the scrubbed payload.
Why scrub tool results too: the assistant has Firestore read access. A query result that returns user records would carry plaintext fields if those rows are pre-Sprint-A (legacy mood field, plaintext email on adminInvites pre-P2.5, etc.). Even post-cleanup, defense in depth makes the request-layer sanitizer the correct chokepoint โ one place to audit, one place to update when new patterns are needed.
Coherence handling: if scrubbing makes the conversation incoherent (e.g., the admin asked a question about a specific phone number that's now <phone>), Claude responds to the scrubbed content. The system-prompt note explains the convention so Claude can answer sensibly. We do NOT block the request on coherence concerns โ the privacy bar is strict, conversational quality is secondary.
7. Observability โ
Two events, both fire counts only (no PII payload):
| Event | When | Fields |
|---|---|---|
admin_assistant_pii_blocked | UI modal shown (admin-typed PII detected) | categories: [phone, email, ...], admin_uid, timestamp |
admin_assistant_pii_scrubbed | Layer 2 sanitizer fires on a request | categories: [phone, email, ...], source: 'admin_input' | 'tool_result' | 'system_prompt', count_per_category, admin_uid, timestamp |
What this gives us:
- Spot patterns: if
admin_inputevents are high, the admin UX needs work (maybe an inline hint, or autocomplete-from-UID) - Spot bugs: if
tool_resultevents are high, we're not encrypting / pseudonymizing at the right layer upstream - Forensic trail: if a question comes up later about whether redaction worked at a given time, the count is queryable
What we do NOT log: the actual PII content, the actual prompts. Counts and categories only.
8. Why UIDs are safe in this context โ
Firebase UIDs (28-char alphanumeric) are intentionally NOT in the scrub list. The reasoning:
- UIDs are opaque pseudonyms. Random strings with no structure, no external mapping, no identity-bearing content. Anthropic can't pivot from a UID to a real person โ the lookup requires Lantern's infra.
- Phone/email are identifying outside our system. A phone number maps to a real carrier account; an email maps to a real inbox. Even if Anthropic doesn't actively misuse them, they sit in logs that could be subpoenaed, breached, or correlated externally.
- The admin needs SOME identifier. Troubleshooting "the user" requires saying which user. UIDs are the least-identifying handle that makes the work possible.
The block-and-warn modal explicitly tells the admin "use the UID instead" โ teaching the convention is part of the design.
Trade-off accepted: Anthropic's logs may carry UID-keyed behavioral correlation (the same UID appearing across multiple admin questions). That's pseudonymous, not identifying, and bounded by Anthropic's retention. We don't try to defeat it.
9. Implementation surface โ
| File | Change |
|---|---|
services/api/assistant/src/services/sanitizer.service.js (new) | Implements the request-layer sanitizer. Exports scrubRequestPayload(request) |
services/api/assistant/src/services/anthropic.service.js (or wherever the API call lives) | Wraps the Anthropic call with scrubRequestPayload before send |
apps/admin/src/admin/chat/LanternChat.jsx | Adds the block-and-warn modal; intercepts submit |
apps/admin/src/lib/piiDetection.js (new) | Shared client-side PII detection โ same patterns as the server-side sanitizer. Exports detectPiiCategories(message) |
packages/shared/privacy/scrubPatterns.js (new, or extend existing) | Single source of truth for the regex patterns. Imported by both client (piiDetection) and server (sanitizer.service). |
Estimated effort: half a day. Most of the work is the modal UX and getting the shared patterns wired through both layers.
10. Open questions / future revisits โ
| Question | Default for v1 | When to revisit |
|---|---|---|
| Pattern coverage (SSN, dates of birth, IP addresses, GPS coords) | Out of v1 scope | If observability shows admins typing those patterns |
| Multilingual phone formats (non-E.164) | E.164 only | If admin-base internationalizes |
| Should the assistant ever be allowed to output PII back to the admin? | No โ but we don't enforce on responses today | If observability shows leak via response, add output-side scrubber |
| Admin opt-out (power-user "I know what I'm doing") | No โ every detection requires explicit "Send scrubbed" click | If observability shows this is genuinely annoying for legitimate workflows |
| Telemetry of "Send scrubbed" frequency per admin | Counts only, per-admin | If it correlates with confused responses, revisit UX |
11. Decision log (append, don't overwrite) โ
- 2026-05-26 โ design conversation.
- Two-layer model: client-side block-and-warn + server-side sanitizer.
- UIDs are NOT scrubbed (recommended pattern); phone/email/JWT/credit-card ARE.
- Block-and-warn modal blocks by default; escape hatch ("Send scrubbed") logs an event but proceeds.
- Tool results scrubbed at request layer regardless of admin choice.
- Counts-only observability (no PII payload in events).