usertrust
Policy

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"
}
ModeBehavior
"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 methodPrompt 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 stringa 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 textthinking, 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.createmessagesthe 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 argumentsnames, 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.createinput and instructions when it is a stringmessage 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 argumentsreasoning 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.generateContentcontentstext parts; functionCall.args and functionResponse.response (their string values); executable code and its output; tool calls' args and tool responsesparts 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 arguments is 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 arguments text 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

  • governAction scans the action's params. 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() and usertrust-server scan the messages the caller passes to authorize. 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.

TypePattern
emaillocal@domain.tld, simplified RFC 5322
phoneUS and international formats, such as +1-555-123-4567 or (555) 123-4567
ssnXXX-XX-XXXX, or a bare or space-separated nine digits that pass the Social Security Administration's structural rules
credit_card13 to 19 digits, Luhn-validated
ipv40-255.0-255.0-255.0-255
api_keysk- (OpenAI and Anthropic) and sk_live_ / sk_test_ (Stripe) keys
aws_keyAKIA followed by 16 upper-case letters or digits
github_tokenghp_, gho_, ghs_, ghr_ or github_pat_ tokens
bearer_tokenBearer followed by a token of 20 or more characters
jwteyJ... three-part JSON Web Tokens
private_keya PEM -----BEGIN ... PRIVATE KEY----- header
generic_secretSlack (xox...), SendGrid (SG.), GitLab (glpat-) and npm (npm_) tokens, and hex strings of 64 or more characters
unscannablenot 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 arguments string 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, httpOptions headers, 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's name or in replayed reasoning, an object key or a number in a tool call's arguments, a value in a field inside the prompt fields that the content list does not name. PolicyDeniedError is thrown before anything is sent. "redact" never sends a value the scan detects.
  • PII in a field the scan does not read (a tools description, a system block array, a Google config.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)"] }