PII Detection
What the pattern scan reads, what it detects, and what it does not cover.
PII detection is a pattern scan of the content of a request's prompt: the text the model reads. It is not a data-loss-prevention system. This page states exactly what it reads, what it matches, what each mode does, and what it leaves alone.
Configuration
Set the pii field in the config file trust() loads: .usertrust/usertrust.config.json by default, or the file you pass as configPath (which is read instead), or pass pii to trust() itself (e.g. trust(client, { pii: "block" })), which overrides the file.
{
"pii": "warn"
}| Mode | Behavior |
|---|---|
"warn" | Default. Forward the request unchanged. The llm_call audit event records the PII types and the paths where they were found, never the values. |
"block" | Throw PolicyDeniedError before anything is sent, when the scan finds PII. |
"redact" | When the scan finds PII, forward a copy of the request in which the content that holds it is replaced, and every other field is as you wrote it. If the scan finds PII in a value redaction leaves as written, throw PolicyDeniedError instead, before anything is sent (see What redaction does). |
"off" | Disable PII detection. |
What the scan reads
The scan reads every string value inside the request's prompt fields, at any depth, and nothing outside them. That includes the control values beside the text: ids, names, URLs, media data, and the model's own replayed reasoning. A tool call's arguments is checked as one string, keys and numbers included.
"redact" rewrites less than that: only the content, the text the model reads, which is a closed list per provider, written from each SDK's own request types. The control values are left as you wrote them, because the provider needs them back unchanged.
| Client method | Prompt fields (scanned) | Content (what "redact" rewrites) | Left as written by "redact" (scanned only inside the prompt fields) |
|---|---|---|---|
Anthropic messages.create, messages.stream, messages.parse (and the beta.messages equivalents) | messages, and system when it is a plain string | a string message; text blocks; a document's title, context and text; search results' title and text; tool_use, server_tool_use and mcp_tool_use input (its string values); tool results, including code execution and bash stdout / stderr, text editor output and fetched pages; advisor results; mid-conversation system text | thinking, redacted_thinking and compaction blocks; images, base64, URL and file documents; citations; ids and names; system given as an array of blocks; tools, tool_choice, metadata, mcp_servers, stop_sequences and every other request field |
OpenAI chat.completions.create | messages | the text of every message and text part; an assistant's refusal; tool calls' arguments (as JSON) and custom tool calls' input; a legacy function_call's arguments | names, tool_call_id, tool call ids; image, audio and file parts; an assistant's audio; tools and functions (definitions), metadata, user, prompt_cache_key, safety_identifier, prediction and every other request field |
OpenAI responses.create | input and instructions when it is a string | message text (input_text, output_text, refusal); function, custom tool and MCP calls' arguments and outputs, an MCP call's error, an MCP approval's reason; shell, local shell and apply_patch outputs; a program's code and result; a tool search call's arguments | reasoning and compaction items, item references; local shell and apply_patch calls (their commands, environment and patches); image and file parts; annotations; ids and names; tools, prompt, metadata, text, previous_response_id and every other request field; content held server-side by an earlier response |
Google models.generateContent | contents | text parts; functionCall.args and functionResponse.response (their string values); executable code and its output; tool calls' args and tool responses | parts marked thought: true or carrying a non-empty thoughtSignature; inline and file data; ids and names; everything in config, including systemInstruction, httpOptions and tools (callable tool declarations, MCP servers) |
A field the content list does not name, such as one a newer SDK adds or a content block of a type it does not know, is not redacted. Under "redact", each controller reports it once, by its path and never its value, as a process warning with the code USERTRUST_PII_UNLISTED_FIELD (also printed with console.warn). A key or block type that is not a plain identifier, or that itself looks like PII, is shown in that path as <not shown>.
Other things the scan never reads:
- Responses. Only the request is scanned. The model's output is not.
- Object keys. Only string values are checked, so a key of an object in the request that spells an email address is not. A tool call's
argumentsis a single string, so the keys in its text are checked. - Non-string values. A phone number stored as a number value is not a string and is not checked. Inside a tool call's
argumentstext it is. - Encoded content. A string is checked as written. Base64, images, PDFs, JSON escapes and other encodings are not decoded.
Governed actions and the headless controller
governActionscans the action'sparams. In"block"mode a match refuses the action. In"redact"mode only the audit copy is redacted; the action itself runs on the original input.createGovernor()andusertrust-serverscan themessagesthe caller passes toauthorize. They support"block". They never redact egress:"redact"behaves like"warn", and redacting what goes to the provider is the integrating host's job.
Detected types
Each string is tested against these patterns. A match anywhere inside the string counts.
| Type | Pattern |
|---|---|
email | local@domain.tld, simplified RFC 5322 |
phone | US and international formats, such as +1-555-123-4567 or (555) 123-4567 |
ssn | XXX-XX-XXXX, or a bare or space-separated nine digits that pass the Social Security Administration's structural rules |
credit_card | 13 to 19 digits, Luhn-validated |
ipv4 | 0-255.0-255.0-255.0-255 |
api_key | sk- (OpenAI and Anthropic) and sk_live_ / sk_test_ (Stripe) keys |
aws_key | AKIA followed by 16 upper-case letters or digits |
github_token | ghp_, gho_, ghs_, ghr_ or github_pat_ tokens |
bearer_token | Bearer followed by a token of 20 or more characters |
jwt | eyJ... three-part JSON Web Tokens |
private_key | a PEM -----BEGIN ... PRIVATE KEY----- header |
generic_secret | Slack (xox...), SendGrid (SG.), GitLab (glpat-) and npm (npm_) tokens, and hex strings of 64 or more characters |
unscannable | not a pattern: a value the patterns could not finish reading, because V8's regular-expression engine ran out of stack on a run of millions of characters. It is treated as holding a secret: refused under "block" and replaced whole with [REDACTED:unscannable] under "redact". The values beside it are checked as usual |
Not detected: personal names, postal addresses, dates of birth, IPv6 addresses, national identifiers other than the US Social Security number, bank account and IBAN numbers, health information, and any value written in a form the patterns above do not match (for example name at example dot com, or a number split across fields).
What redaction does
When the scan finds PII, usertrust forwards a copy of the request in which every content string that matches a pattern is replaced by [REDACTED:<types>], for example [REDACTED:email,phone].
- The replacement is for the whole string value, not just the matched characters: a message that contains one email address is replaced in full.
- A tool call's JSON
argumentsstring keeps its text: only the string values inside it that match are replaced, in place, so it is still valid JSON and every other character (keys, numbers of any size, whitespace) is sent as written. A string that is not JSON is replaced in full. Keys are never replaced. - To find those values, redaction reads the content as the SDK sends it: it decodes a tool call's JSON
arguments, so it also replaces a value written with JSON escapes, and it reads a Google text part hidden from enumeration."block"refuses what this read finds as well as what the scan finds. - Every control field is sent as you wrote it: MCP server credentials and headers,
httpOptionsheaders, cache keys, safety identifiers,metadata, ids, and replayed reasoning, which the provider refuses if it is changed. They are not redacted even when the prompt beside them is. - PII the scan finds in a value redaction leaves as written refuses the call, as
"block"does: an address in a message'snameor in replayed reasoning, an object key or a number in a tool call'sarguments, a value in a field inside the prompt fields that the content list does not name.PolicyDeniedErroris thrown before anything is sent."redact"never sends a value the scan detects. - PII in a field the scan does not read (a
toolsdescription, asystemblock array, a Googleconfig.systemInstruction) goes to the provider as written. - Your own object is never changed; usertrust redacts a copy.
What is recorded
With "warn" or "redact", the llm_call audit event carries piiDetected (the types) and piiPaths (where) for what either read finds. A path from the scan starts at the prompt fields, for example [0].content(email); a path only the content read adds (a value written with JSON escapes in a tool call's arguments, a hidden Google text part) starts at the request root, for example messages[1].tool_calls[0].function.arguments(email). The values themselves are never stored.
PIIDetection result
interface PIIDetection {
found: boolean;
types: string[]; // ["email", "ssn", ...]
paths: string[]; // paths where PII was found, e.g. "messages[0].content(email)"
}Function
function detectPII(data: unknown): PIIDetection;Recursively walks objects and arrays, checking string values against all PII patterns.
Example
import { detectPII } from "usertrust";
const result = detectPII({
messages: [
{ role: "user", content: "My email is test@example.com and SSN is 123-45-6789" }
]
});
// { found: true, types: ["email", "ssn"], paths: ["messages[0].content(email)", "messages[0].content(ssn)"] }