Skip to content
tnsaiDocs
GitHub

Redact sensitive data

A redactor replaces personal data and secrets with placeholders before text leaves the framework: memory writes, agent events and LLM call logs. Redaction runs only where you wrap a sink with a redacting decorator.

The redactors are in tnsai-quality, package com.tnsai.quality.redaction. For path and credential masking without that module, see Mask paths and credentials. To mask PII in action input, see PII input guardrails.

Quick start

static AgentBuilder redacted(AgentBuilder builder, LLMClient base) {
    Redactor redactor = PatternRedactor.withDefaults();

    // Conversation memory: messages are scrubbed before they are stored.
    MemoryStore memory = new RedactingMemoryStore(new InMemoryStore(), redactor);

    // Agent events: every field that can carry user input is scrubbed.
    AgentEvents.setPublisher(
        new RedactingAgentEventPublisher(new Slf4jAgentEventPublisher(), redactor));

    // LLM call logs: prompts, responses, tool arguments and errors are scrubbed.
    LLMClient observed = new CapturingLLMClient(base, JsonLLMPricingRegistry.defaultRegistry(),
        new RedactingLLMCallPublisher(new Slf4jLLMCallPublisher(), redactor));

    return builder.llm(observed).memoryStore(memory);
}
SinkDecoratorWhat gets scrubbed
Conversation memoryRedactingMemoryStoreaddMessage content and search queries. Reads return what was stored, which is already redacted
Agent eventsRedactingAgentEventPublisherEvery field that can carry user input, in all TnsAIEvent variants
LLM call logsRedactingLLMCallPublisherSystem prompt, messages, parameters, response content, tool call arguments, reasoning text, error message and provider extensions. Ids, timings, token counts and cost pass through

AgentEvents.setPublisher replaces the publisher for the whole JVM. To stack redaction with sampling, see Sampling.

Default patterns

PatternRedactor.withDefaults() uses RedactionPatterns.defaults(). Each pattern is also a constant on RedactionPatterns, such as RedactionPatterns.EMAIL.

NameSeverityMatches
emailMEDIUMuser@example.com
phoneMEDIUM+15551234567
credit_cardCRITICAL4111-1111-1111-1111, only when it passes the Luhn check
us_ssnCRITICAL123-45-6789
tc_kimlikCRITICAL11-digit Turkish identity numbers with a valid checksum
openai_api_keyHIGHsk-...
anthropic_api_keyHIGHsk-ant-...
github_patHIGHghp_...
aws_access_keyHIGHAKIA...
jwtHIGHThree base64url segments separated by ., including unsigned tokens
ibanMEDIUMDE89370400440532013000
ipv4LOW10.0.0.1
bearer_tokenHIGHBearer <token>
ssh_private_key_blockCRITICALPEM and PGP private key blocks. A block cut off before its END line is hidden to the end of the text

A match becomes [REDACTED:<name>], for example [REDACTED:email]. No default pattern matches a placeholder, so redacting text twice gives the same result.

Custom patterns

static Redactor withCustomPatterns() {
    RedactionPattern medicalRecord = RedactionPattern.of(
        "medical_record_id", "MRN-\\d{8}", Severity.CRITICAL);
    RedactionPattern plate = RedactionPattern.of(
        "license_plate_tr", "\\b\\d{2}\\s?[A-Z]{1,3}\\s?\\d{2,4}\\b", Severity.MEDIUM,
        match -> match.length() >= 7);         // runs after the regex matches

    List<RedactionPattern> patterns = new ArrayList<>(RedactionPatterns.defaults());
    patterns.add(medicalRecord);
    patterns.add(plate);
    return new PatternRedactor(patterns);
}

The optional validator checks each regex match, for rules a regex cannot express, such as a checksum. A pattern without a validator accepts every match.

Combine redactors

Every type below implements Redactor, so they nest freely.

TypeWhat it does
CompositeRedactor.of(a, b, ...)Runs redactors in order; each one gets the previous output
TenantPolicyRedactorPicks a redactor per tenant
AuditingRedactorReports each call that found something to a RedactionAuditListener
LoggingRedactionAuditListenerLogs one line per audit event
static Redactor perTenant() {
    Redactor everything = PatternRedactor.withDefaults();
    Redactor cardsOnly = new PatternRedactor(List.of(RedactionPatterns.CREDIT_CARD, RedactionPatterns.US_SSN));

    Redactor dispatcher = TenantPolicyRedactor.builder()
        .tenant("acme", cardsOnly)
        .defaultRedactor(everything)
        .build();
    return new AuditingRedactor(dispatcher, LoggingRedactionAuditListener.atInfo());
}

TenantPolicyRedactor looks up the tenant on every call: first RedactionContext.tenantPolicyId(), then the tenant id of the call's EventContext. With no tenant, or one that is not registered, it uses the default redactor.

A RedactionAuditEvent carries the scope, the event context, a count per pattern, the highest severity and the total number of findings. It never carries the redacted text. LoggingRedactionAuditListener.atInfo() and atDebug() log at that level, and always log CRITICAL findings at WARN. An exception thrown by a listener is logged and does not stop redaction.

Mask paths and credentials

com.tnsai.redaction in tnsai-core holds maskers that need no tnsai-quality dependency.

static void mask(Path runFolder, Path homeFolder) {
    Masker outside = Masker.hidingOutside(runFolder);
    outside.apply("read /home/ann/.ssh/id_rsa");     // "read [REDACTED:path]"

    Masker names = new PathRedactor(Map.of(runFolder, "<run>", homeFolder, "~"));
    names.apply(runFolder + "/notes.log");           // "<run>/notes.log"
}
TypeWhat it does
MaskerA UnaryOperator<String>. Masker.noOp() changes nothing and is the default wherever masking is configurable
Masker.hidingOutside(root)Replaces every absolute POSIX path outside root with [REDACTED:path], and every credential with [REDACTED:<name>] using the names above. root/../etc/passwd counts as outside. It does not mask PII
PathRedactorReplaces each folder in the map with the name you give it, longest match first
OncePerPathWarnerLogs at most one warning per path, with the path, message and cause passed through a Masker
CredentialPatternsThe credential patterns shared with PatternRedactor, plus Slack tokens and webhooks for the masker

hidingOutside does not recognize relative paths, ~/..., Windows drive paths, percent-encoded paths, the path part of a URL, or the part of a path after a space. PathRedactor matches a folder only in the form the platform writes it.

Write your own redactor

Redactor has three methods:

  • scrubString(input, context) returns a RedactionResult with the new text and one RedactionFinding per match (pattern, offsets, placeholder, severity).
  • scrubValue(value, context) walks maps and lists and scrubs every string in them. It has a default implementation.
  • maybeContainsSensitive(input) is a cheap pre-check.

An implementation must be thread-safe, because one instance serves every sink. It must also be idempotent: scrubbing the output again gives the same text. RedactionContext names the sink through RedactionScope, for example MEMORY_WRITE, LLM_PROMPT or LLM_RESPONSE.

Trade-offs

  • Patterns can match harmless text, such as an order number shaped like a phone number. Add a validator to the pattern to tighten it.
  • Formats outside the catalog, such as names, addresses or driver's licences, are not caught. Add custom patterns or your own Redactor.
  • Redacting very large payloads, such as long LLM responses, adds latency. Put sampling before redaction so only the events that are kept get scrubbed.