PII detection (Presidio)

The regex pii-exposure rule and the data boundary catch formatted identifiers: an SSN, a card number, a phone number, an email, an IBAN. They do not recognise a person's name, a place, or an NHS number written in a sentence. Cloak is a different control: it vaults secrets you register and substitutes @@SECRET:name@@ at execution time.

This page is the optional third control. It runs Microsoft Presidio (presidio-analyzer and presidio-anonymizer) over prompts, tool-call arguments and tool results, and records a pii-ner finding. It is off by default. A default hook never imports Presidio, so that path stays on the regex budget.

Install

Presidio and spaCy are not dependencies of prismor. Python 3.9 or newer:

pip install "prismor[pii]"
python -m spacy download en_core_web_sm

en_core_web_sm is the small English model (about 12 MB). The detector does not load en_core_web_lg. If the extra or the model is missing and backend is presidio, detection falls back to the regex classifier and prints one line on stderr. Names, locations and medical-license numbers are listed on the finding as piiUnsupported in that case. The call is not failed.

Enable

In .prismor/policy.yaml:

version: "1.0"
rules: []
settings:
  pii:
    enabled: true
    action: observe    # observe | redact | block
    mode: observe      # observe | enforce

action is what a hit should do. mode is whether that action is live, the same split as every other rule:

actionmodewhat happens
observeeithera finding is recorded, the call is unchanged
redactobservea finding is recorded, the call is unchanged
redactenforcethe value is masked, then the call continues
blockobservea finding is recorded, the call is unchanged
blockenforcea pre-action call is refused; a gateway tool result is withheld

Start with observe. block refuses any pre-action call whose text contains a hit, including an echo of a name. redact rewrites the text in place (transform: pii_ner_redact on tool input, and the shared result redactor on tool output).

Check a string without running it:

prismor check --type text "email Ada Lovelace at [email protected]"

example.com, 555 phone numbers and the published test card numbers are treated as fixtures and do not produce a pii-ner finding. The regex pii-exposure rule is unchanged and can still match a formatted SSN on its own.

What it detects

Default entity list:

EntityNeeds the spaCy model
PERSON, LOCATION, MEDICAL_LICENSEyes
EMAIL_ADDRESS, PHONE_NUMBER, CREDIT_CARD, IBAN_CODE, US_SSN, UK_NHS, IP_ADDRESSno (regex recognisers; the fallback covers these)

IP_ADDRESS from the regex fallback scores 0.6, so a score_threshold of 0.7 drops it. Dotted version numbers can look like addresses; remove IP_ADDRESS from entities if that noise matters. DATE_TIME and URL are not in the default list because they fire on ordinary text. Add them under entities if you want them.

Other Presidio types (US_PASSPORT, US_ITIN, UK_NINO, …) work once you name them. The language defaults to en. Another language needs a spaCy model for that language and language set to its code.

Configuration

settings:
  pii:
    enabled: false
    backend: presidio          # presidio | regex
    language: en
    model: en_core_web_sm
    score_threshold: 0.5
    action: observe            # observe | redact | block
    mode: observe              # observe | enforce
    mask_style: mask           # mask | replace | hash | redact
    max_chars: 4000
    entities: [PERSON, EMAIL_ADDRESS, PHONE_NUMBER, UK_NHS]
    fields: [prompt, response, content, stdout, stderr, command, mcp_args, outbound_payload, typed_text]
    allow_list: []             # values never reported, case-insensitive

The spaCy models tag ordinary developer words as people or places. On coding text, en_core_web_sm flagged "Django", "Claude", "Jira", "git commit" and "lorem". Every spaCy hit scores 0.85, so score_threshold cannot separate them; en_core_web_lg was not meaningfully better (3 of 8 developer sentences flagged instead of 4). List the names your team uses in allow_list, or keep PERSON and LOCATION for business-text surfaces such as Copilot Studio.

A project file that sets only enabled: true keeps the defaults above. The stanza merges like semantic_guard.

mask_style is how a redacted value is replaced:

stylereplacement
maskthe span, every character a •
replace<PERSON>, <EMAIL_ADDRESS>, …
redact[REDACTED]
hash16 hex characters of SHA-256

hash is not a secret. A short identifier (SSN, NHS number, phone) can be guessed from it. Use mask or replace for those.

max_chars is the size of each detector call (default 4000, maximum 50000). A longer field is scanned in overlapping windows of that size, so the whole field is checked and masked; cost grows with the length of the text.

backend: regex never imports Presidio. Use it where you want the structured entities without a model. PERSON, LOCATION and MEDICAL_LICENSE stay unsupported.

Script bodies walked line by line, and the SessionStart skills audit, are not scanned. One model pass per line would dominate the call. The shell command that invokes the script is still scanned.

Where a finding is recorded

The finding uses the existing pii_exposure category and the rule id pii-ner, so it shows up in prismor sessions, the dashboard and redacted telemetry the same way pii-exposure does. Stored fields are the entity type, a score, and a mask. The value itself is not written to the finding, the title, or the stderr line.

An allowlist on rule pii-ner suppresses a hit when its pattern matches the scanned text. The match happens in memory. The raw text is not stored.

prismor doctor lists the layer as pii: off until you enable it, then pii: observe (presidio, en_core_web_sm) or pii: observe (regex fallback, …).

Result redaction

Surfaces that already pass their policy engine into the shared redactor mask tool output when action is redact and mode is enforce: the MCP gateway, the LLM proxy, POST /v1/redact, and framework adapters that pass engine=. The gateway scans once while it masks, and does not run the model again on the masked text. A coding-agent hook cannot rewrite tool output; it can refuse the call (block) or rewrite tool input (redact via pii_ner_redact). prismor pause suspends this pass with the rest of policy. Cloak masking keeps running.

Latency

One run of python3 scripts/bench_pii.py (190-character prompt, median after a warmup):

pathtime
evaluate, PII disabled (the default)0.78 ms
evaluate, regex backend1.00 ms
detect(), regex only0.10 ms
Presidio detect(), first call in the process364 ms
Presidio evaluate, warm, 190 characters6.9 ms
Presidio evaluate, warm, 4000 characters76 ms
Presidio detect(), warm5.8 ms

Re-run the script on the machine you deploy to. The shape is what matters: the disabled path stays under a millisecond, and a warm Presidio call on a short prompt is a few milliseconds once the model is in the process. The first call in a process pays the spaCy load (a few hundred milliseconds here). Two consequences follow from how hooks work:

  • Default installs are unchanged. enabled is false. Presidio is not imported. A coding-agent hook stays on the regex budget (about 1 ms).
  • The model is cached per process. The eval-server, the MCP gateway, the LLM proxy and an in-process framework adapter pay the load once. A coding-agent hook is a new process per call, so enabling Presidio there adds the cold-start cost to every tool call. Prefer it on the long-lived surfaces (Copilot Studio goes through the eval-server) unless you have measured the hook overhead and accepted it.

Cost grows with text length: a multi-kilobyte tool result is scanned in full, max_chars at a time.

Copilot Studio and other surfaces

Copilot Studio sends the planned tool call and earlier tool outputs to the eval-server as prompt and tool_result events. Those fields are in the default fields list, so the same stanza covers them. No separate provider configuration.

See also: Data boundary, Sweep and Cloak, Decision contract, Copilot Studio.