Skip to content

Redacting Indian personal data in R before it reaches an LLM

R users are wiring language models into their work quickly: summarising call notes with ellmer, classifying tickets, drafting replies. In a bank, an insurer or a hospital those notes are full of Aadhaar numbers, PANs and mobile numbers, and the DPDP Act, RBI guidance and plain caution all say they should not go to a model that does not need them - often not even to one running in-house on a shared server.

credgate is an R package (with a Python twin) that finds Indian personal data and takes it out before the prompt leaves your session. This post shows the R side.

Find it

# install.packages("credgate", repos = c("https://dwarpal.r-universe.dev", "https://cloud.r-project.org"))
library(credgate)

note <- "Customer name: Priya Nair. PAN ABCPN1234K, mobile +91 98765 43210, wants the EMI date moved."
detect_pii(note)
#> 3 PII matches (values masked; use `x$value` to see them):
#>  doc start end        type           value confidence
#>    1    16  25 person_name      XXXXX XXXX       0.80
#>    1    32  41         pan      XXXXXX234K       0.97
#>    1    51  65   mobile_in +XX XXXXX X3210       0.97

Every match has a confidence and, if you ask, the reasons for it. These are not just patterns: an Aadhaar number must pass its Verhoeff checksum, a card number its Luhn check, a GSTIN its check character, so a mistyped or made-up number is not reported. Words nearby raise or lower the confidence: "PAN" next to a PAN-shaped value makes it near certain, "order no" next to a 12-digit number makes it unlikely to be an Aadhaar. Values are masked whenever credgate prints them, so a console log or a knitted report does not leak them either.

It reads Indian text as it is written, Devanagari digits included:

detect_pii("आधार: ५४२२ २९३७ ११८२, मोबाइल 9468451273")
#> 2 PII matches (values masked; use `x$value` to see them):
#>  doc start end      type          value confidence
#>    1     7  20   aadhaar XXXX XXXX ११८२       0.97
#>    1    30  39 mobile_in     XXXXXX1273       0.93

Look before you send

preview() shows exactly what would be replaced, and why:

preview(note, min_confidence = 0.5)
#> 3 items would be redacted:
#>   Customer name: [PERSON_NAME]. PAN [PAN], mobile [MOBILE_IN], wants the EMI date moved.
#>
#>  placeholder   value           confidence reasons
#>  [PERSON_NAME] XXXXX XXXX      0.80       matched Person name pattern
#>  [PAN]         XXXXXX234K      0.97       matched PAN pattern; context 'PAN'; holder type: individual
#>  [MOBILE_IN]   +XX XXXXX X3210 0.97       matched Indian mobile number pattern; context 'mob'

Redact, then ask the model

library(ellmer)

safe <- redact(note, min_confidence = 0.5)
safe
#> [1] "Customer name: [PERSON_NAME]. PAN [PAN], mobile [MOBILE_IN], wants the EMI date moved."

chat_ollama(model = "gemma3:1b")$chat(paste("Summarise in one line:", safe))

The model only ever sees [PAN] and [MOBILE_IN]. The same works with chat_openai(), chat_anthropic(), chat_vllm() or any other provider: credgate runs before the request, in your R session, and makes no network calls of its own.

Need the real values back? Tokenise

Redaction is one-way. When the answer should contain the real values - a drafted reply to the customer, say - tokenise instead: the model sees numbered tokens, and restore() puts the values back into its answer.

vault <- cg_vault()
tokenise(note, vault, min_confidence = 0.5)
#> [1] "Customer name: [PERSON_NAME_1]. PAN [PAN_1], mobile [MOBILE_IN_1], wants the EMI date moved."

answer <- "Dear [PERSON_NAME_1], your request to move the EMI date for PAN [PAN_1] is noted."
restore(answer, vault)
#> [1] "Dear Priya Nair, your request to move the EMI date for PAN ABCPN1234K is noted."

The vault stays in your session; vault_save() writes it encrypted with a passphrase if you need it later.

Data frames

Before a table goes into a prompt, a RAG index or a shared file, find out which columns hold personal data:

calls <- data.frame(
  id = 1:4,
  cust_pan = c("ABCPE1234F", "AAACR5055K", "XYZPQ9876R", "BNZPM2501F"),
  notes = c("Called, Aadhaar 2345 6789 0124", "no issues", "mobile 9876543210", "asked about EMI")
)
scan_pii(calls)
#> Scanned 3 columns; PII found in 2: cust_pan and notes
#>    column      type rate matched whole confidence        example
#>  cust_pan       pan 100%  4 of 4     4       0.97     XXXXXX234F
#>     notes   aadhaar  25%  1 of 4     0       0.97 XXXX XXXX 0124
#>     notes mobile_in  25%  1 of 4     0       0.93     XXXXXX3210

For tables too big for memory, the same functions work on Arrow datasets, Parquet files, DuckDB and other databases through DBI, and Spark through sparklyr, sampling where the data lives. See large data.

What it does not do (yet)

  • Names in running text. credgate finds names after a title, label or relation ("Smt.", "Name:", "S/o"), not in a sentence like "Delivery to Sandeep Pillai". If you know a column holds names, say so and every value is read as one; for notes, add your own name list or cues as a custom detector.
  • Evasion. It handles accidental variation - Indic digits, hidden characters, mixed case - not someone deliberately spelling a PAN out letter by letter.

Going further

  • Policies: which models may see what, decided by InfoSec in one YAML file and applied with policy_apply(); every decision can go to a tamper-evident audit log.
  • A proxy (from the Python package) applies the same policy to every request from Claude Code, Copilot Chat or ellmer pointed at it.
  • How a match is scored, step by step.

credgate is open source (Apache-2.0) and alpha: we are looking for people to try it and tell us what it misses. Report a detection problem with a synthetic example, or start a discussion.

All values in this post are synthetic.