Skip to content

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:

SurfaceWithout redactionWith this design
Anthropic API request logsCarry user phone/email/JWT in promptsCarry pseudonyms only (<phone>, <email>, <jwt>)
Anthropic conversation history (if persisted by them)Same as aboveSame as above
Subpoena targeting AnthropicAnthropic can produce PII from their logsAnthropic produces only scrubbed prompts
Misconfigured opt-in to trainingUser PII could enter training corpusScrubbed prompts only
Operational accident (admin pastes a wall of records)100 users' PII to Anthropic in one clickBlock-and-warn fires before send

What stays visible to Anthropic by design:

SurfaceVisibilityWhy we accept it
Firebase UIDsYes (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)YesSame โ€” these are app-internal display names, not real identity
Admin question semanticsYesThe 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:

PatternRegexPlaceholderNotes
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.
Email[\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.
JWTeyJ[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:

PatternWhy 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 namesApp-internal pseudonyms; not PII
Timestamps, hashes, file pathsOperationally 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 send defeating 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:

  1. Walks the entire request payload: system prompt, all messages in the conversation history, all tool results inline.
  2. Applies the ยง4 scrub patterns to every string value.
  3. 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.
  4. Emits an observability event (ยง7).
  5. 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):

EventWhenFields
admin_assistant_pii_blockedUI modal shown (admin-typed PII detected)categories: [phone, email, ...], admin_uid, timestamp
admin_assistant_pii_scrubbedLayer 2 sanitizer fires on a requestcategories: [phone, email, ...], source: 'admin_input' | 'tool_result' | 'system_prompt', count_per_category, admin_uid, timestamp

What this gives us:

  • Spot patterns: if admin_input events are high, the admin UX needs work (maybe an inline hint, or autocomplete-from-UID)
  • Spot bugs: if tool_result events 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 โ€‹

FileChange
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.jsxAdds 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 โ€‹

QuestionDefault for v1When to revisit
Pattern coverage (SSN, dates of birth, IP addresses, GPS coords)Out of v1 scopeIf observability shows admins typing those patterns
Multilingual phone formats (non-E.164)E.164 onlyIf admin-base internationalizes
Should the assistant ever be allowed to output PII back to the admin?No โ€” but we don't enforce on responses todayIf 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" clickIf observability shows this is genuinely annoying for legitimate workflows
Telemetry of "Send scrubbed" frequency per adminCounts only, per-adminIf 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).

Built with VitePress