Tramai Logo
Tramai

DLP — Data Loss Prevention

Experimental

The DlpInterceptor SPI is stable. RuleBasedDlpInterceptor, DlpRedactionAuditEmitter, and AuditEngineDlpRedactionAuditEmitter are still evolving. Configuration APIs may change in minor releases.

TramAI's DLP (Data Loss Prevention) system intercepts model outputs and tool results before they reach downstream consumers, structured parsers, or cache storage. It applies configurable redaction rules to prevent sensitive data — PII, secrets, credentials — from leaving the AI integration boundary.

Package: dev.tramai.core.security (SPIs), dev.tramai.security (RuleBasedDlpInterceptor, AuditEngineDlpRedactionAuditEmitter)


What

The DLP system is built on four layers:

  1. DlpInterceptor — a fun interface: fun inspect(context: DlpContext, text: String): DlpResult
  2. RuleBasedDlpInterceptor — the built-in regex implementation with RuleBasedDlpConfiguration and DlpRule
  3. DlpRedactionAuditEmitter — a fun interface for recording redaction events: suspend fun emit(context: DlpContext, redactions: List<DlpRedaction>)
  4. DlpInspectionException — thrown when inspection fails; distinct from provider failures, does not count toward circuit breakers, does not trigger retry

DLP Execution Point

The engine applies DLP at the earliest-safe boundary in callProviderWithRetries():

interceptResponse()   →   DLP inspect (if configured)   →   onProviderResponse()

The DLP sanitization happens after response interceptors have transformed the raw provider response and before:

  • onProviderResponse() observation
  • Structured output parsing
  • Cache storage
  • The result reaching the application

For tool results, DLP is applied during message reinjection via sanitizeToolMessageForReinjection().

Cache Semantics

Cache eligibility checks the DLP interceptor type. If the interceptor is not NoOpDlpInterceptor, caching is disabled (isSafeCacheEligible requires dlpInterceptor === NoOpDlpInterceptor). This ensures redacted results are never cached and served to subsequent operations that might not apply the same redaction rules.

DLP Failure Model

DLP failures are explicitly not provider failures:

  • The circuit breaker is NOT triggered
  • The failure observer (onProviderFailure) is NOT called
  • No retry is attempted (DLP is deterministic per response)
  • No fallback is executed
  • The error propagates immediately to the caller via DlpInspectionException

When to Use

Use DLP when:

  • You need to redact PII (emails, phone numbers, SSNs, credit cards) from model outputs
  • You need to redact secrets (API keys, tokens, credentials) from tool results
  • You need provider-agnostic redaction that works across all model providers
  • You need content-type-aware rules (different rules for model output vs. tool results)
  • You need tool-scoped rules (redact only for specific tools)
  • You need audit logging of redaction events with per-rule counts

Do not use DLP for input validation — that is the responsibility of the application layer before the TramAI call.


Quickstart

Kotlin DSL

val tramai = Tramai {
    provider(openAiProvider, name = "openai", default = true)
    model("gpt-4o", "openai")

    dlp(
        RuleBasedDlpInterceptor(
            RuleBasedDlpConfiguration(
                maxTextLength = 100_000,
                rules = listOf(
                    DlpRule(
                        id = "email",
                        pattern = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}",
                        replacement = "[EMAIL REDACTED]",
                        enabledFor = setOf(DlpContentType.MODEL_OUTPUT),
                    ),
                    DlpRule(
                        id = "ssn",
                        pattern = "\\d{3}-\\d{2}-\\d{4}",
                        replacement = "[SSN REDACTED]",
                        enabledFor = setOf(DlpContentType.MODEL_OUTPUT, DlpContentType.TOOL_RESULT),
                    ),
                ),
            ),
        ),
    )
}

Sovereign Builder

SovereignTramai.builder()
    .profile(profile)
    .modelRegistry(registry)
    .auditStore(auditStore)
    .provider(ollamaProvider, name = "ollama", default = true)
    .model("llama3.2", "ollama")
    .dlp(
        RuleBasedDlpInterceptor(
            RuleBasedDlpConfiguration(
                rules = listOf(
                    DlpRule(
                        id = "api-key",
                        pattern = "sk-[a-zA-Z0-9]{32,}",
                        replacement = "[API KEY REDACTED]",
                    ),
                ),
            ),
        ),
    )
    .build()

Spring Boot

Define exactly one DlpInterceptor bean. Multiple beans cause a fail-fast startup error:

@Bean
fun dlpInterceptor(): DlpInterceptor = RuleBasedDlpInterceptor(
    RuleBasedDlpConfiguration(
        rules = listOf(
            DlpRule(id = "email", pattern = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"),
        ),
    ),
)

Define at most one DlpRedactionAuditEmitter bean:

@Bean
fun dlpRedactionAuditEmitter(): DlpRedactionAuditEmitter =
    AuditEngineDlpRedactionAuditEmitter(auditEngine)

Rule Configuration Reference

DlpRule Fields

FieldRequiredDefaultDescription
idYesUnique rule identifier (normalized via DlpRuleIdNormalizer.normalize())
patternYesJava regex pattern (compiled via Pattern.compile())
replacementNo"[REDACTED]"Replacement text (uses Matcher.quoteReplacement())
enabledForNosetOf(DlpContentType.MODEL_OUTPUT)Content types this rule applies to
toolNamesNoemptySet() (all tools)Restrict rule to specific tool names

RuleBasedDlpConfiguration Fields

FieldRequiredDefaultDescription
rulesNoemptyList()Ordered list of DLP rules
maxTextLengthNo100_000Maximum input text length (1 to 10,000,000)

Validation

At construction, RuleBasedDlpInterceptor validates:

  • maxTextLength must be 1..10,000,000
  • Duplicate rule IDs (after normalization) are rejected
  • Each pattern must be non-blank and valid regex
  • Each rule must have at least one enabled content type
  • Tool names must not be blank or have surrounding whitespace
  • Zero-width matches are rejected at runtime
  • Replacements that preserve matched content are rejected (case-insensitive check)

DLP SPI Reference

Custom DlpInterceptor

Implement the fun interface directly:

class CustomDlpInterceptor : DlpInterceptor {
    override fun inspect(context: DlpContext, text: String): DlpResult {
        val sanitized = text.replace(Regex("my-sensitive-pattern"), "[REDACTED]")
        return DlpResult(
            sanitizedText = sanitized,
            redactions = listOf(DlpRedaction(ruleId = "custom", replacementCount = 1)),
        )
    }
}

DlpContext

Inspection context with metadata about the operation:

FieldTypeDescription
contentTypeDlpContentTypeMODEL_OUTPUT or TOOL_RESULT
contentLocationDlpContentLocationExact boundary: MODEL_RESPONSE_CONTENT, TOOL_MESSAGE_CONTENT, TOOL_MESSAGE_TEXT_RUN
operationInterfaceStringThe @AiService interface name
operationMethodStringThe method name
providerIdString?Provider that produced the output
modelNameString?Model that produced the output
toolNameString?Tool being executed (for tool results)
correlationIdStringOperation correlation ID
workflowRunIdString?Workflow run ID (if in orchestration context)
dataClassificationDataClassification?Classification of the data
classificationSourceClassificationSource?Source of the classification

DlpResult

FieldTypeDescription
sanitizedTextStringThe redacted text (authoritative output)
redactionsList<DlpRedaction>Per-rule redaction evidence (rule ID + count)
hasRedactionsBooleanConvenience: redactions.isNotEmpty()

DlpRedaction

Contains ruleId (String) and replacementCount (Int). Deliberately excludes raw matched values and input text to prevent accidental sensitive data leakage through audit or exception paths.

DlpInspectionException

class DlpInspectionException(
    message: String = "DLP inspection failed",
    cause: Throwable? = null,
) : RuntimeException(message, cause)

Redaction Audit Bridge

DlpRedactionAuditEmitter SPI

fun interface DlpRedactionAuditEmitter {
    suspend fun emit(context: DlpContext, redactions: List<DlpRedaction>)
}

The engine calls emit() after a successful authoritative DLP inspection that produced redactions. If emission fails, the failure is wrapped in a DlpInspectionException.

NoOpDlpRedactionAuditEmitter

Default no-op implementation that silently discards all audit events. Used when no audit emitter is configured.

AuditEngineDlpRedactionAuditEmitter Implementation

Bridges DLP redactions to the sovereign audit engine (in tramai-security):

val auditEngine = AuditEngine(store = auditStore, clock = clock)
val dlpAuditEmitter = AuditEngineDlpRedactionAuditEmitter(auditEngine)

SovereignTramai.builder()
    // ...
    .dlp(dlpInterceptor)
    .dlpRedactionAudit(dlpAuditEmitter)
    .build()

Each redaction emits an audit event with:

  • Enforcement point: "DLP_MODEL_OUTPUT" or "DLP_TOOL_RESULT" depending on content type
  • Decision: "REDACTED"
  • Reason code: "dlp_redaction_applied"
  • Metadata: contentType, contentLocation, operationInterface, operationMethod, ruleId, replacementCount, providerName, modelName, toolName, classification, classificationSource

The emitter also supports a custom DlpAuditStreamIdResolver for controlling audit stream IDs.


Error Handling

DLP failures throw DlpInspectionException. The engine's catch ordering for DLP inspection is:

try {
    // DLP inspection
} catch (e: CancellationException) {
    throw e  // coroutine cancellation passes through
} catch (e: DlpInspectionException) {
    throw e  // DLP failures propagate directly
} catch (e: Exception) {
    // Wrap unexpected failures
    throw DlpInspectionException("DLP inspection failed", e)
}

Application-level handling:

try {
    val result = service.analyze(text)
} catch (e: DlpInspectionException) {
    // DLP failure — text could not be inspected; circuit breaker NOT triggered
}

Cross-Boundary Detection

The DLP system detects sensitive text across two boundaries during tool result sanitization:

  1. Aggregate text limits — total text submitted for inspection per tool
  2. Sanitized text limits — size of text after redaction

Both limits are enforced by the engine's toolResultFilteringSettings.maxAggregateTextLengthForTool(toolName). Exceeding either limit throws DlpInspectionException with a descriptive message and emits a tramai.dlp.tool_result_rejected engine event.

Engine events are also emitted for cross-boundary sensitive text detection and inspection failures.


Limitations

  • Regex-only — pattern-based redaction only; no ML/NER-based detection
  • No input interception — DLP applies to model outputs and tool results, not to user inputs
  • Cache bypass for custom interceptors — any non-NoOpDlpInterceptor disables caching
  • Redaction evidence excludes raw valuesDlpRedaction carries rule ID and count only (by design, to prevent leakage)
  • Deterministic, no retry — DLP failure is immediate and non-recoverable for that response

Minimum Configuration

Tramai {
    dlp(RuleBasedDlpInterceptor(RuleBasedDlpConfiguration()))
}

A RuleBasedDlpInterceptor with an empty rule list passes all text through unmodified — equivalent to NoOpDlpInterceptor but with rule validation overhead.


Next Steps