OGuardAI
Security

Security Whitepaper

OGuardAI security architecture, trust boundaries, encryption model, and operational security properties

Version: 1.0 | Date: 2026-04-15


Executive Summary

OGuardAI is a semantic data protection runtime that sits between enterprise applications and AI providers. It replaces sensitive data with semantic tokens before data reaches any AI model, and deterministically restores values afterward. This document describes OGuardAI's security architecture, trust boundaries, encryption model, and operational security properties for enterprise auditors, security teams, and compliance officers.

Unlike conventional data masking tools that strip data to opaque redactions, OGuardAI replaces sensitive values with typed tokens ({{type:id:cap}}, e.g., {{email:e_001:7b1068662ab5}}). The token carries an entity-type signal (email, person, phone) intended to preserve more context for the model than a generic [REDACTED] placeholder, so the model can keep referring to the entity coherently without ever seeing the raw value. Any additional metadata (gender, formality, language) is returned separately and must be deliberately included by the integration; it is not attached to the token itself. Output quality is not guaranteed and depends on the model, prompt, and policy. After the model responds, OGuardAI deterministically restores the original values using an encrypted session mapping.

This whitepaper covers: the trust boundary model, encryption and session security, authentication and authorization, multi-tenant isolation, prompt security, the detection model, operational security, deployment security, and supply chain properties.


1. Trust Boundary Model

Architecture

OGuardAI defines two strict zones with a cryptographic boundary between them:

  • Trusted Zone (OGuardAI Runtime): Contains raw PII, token-to-value mappings, encryption keys, and session state. Its core is the server process itself, a single Rust binary running in the customer's infrastructure. The trust boundary also covers the optional Python NER sidecar when deployed (the sidecar receives raw text for detection, so it sits inside the boundary and must stay on an internal network; it never holds encryption keys or session state), the transparent proxy when deployed (it terminates the client request, runs the same transform, and only forwards tokenized text to the provider), and the guarded SDK helpers running inside the customer application (see Inbound Trusted Path below).
  • Untrusted Zone (downstream of protection): LLMs, external tools, logs, vector stores, proxy targets (the upstream AI provider), monitoring systems, and any SIEM or analytics sink. These destinations only ever receive tokenized text, safe metadata, and encrypted session blobs, with one deliberate exception: values the policy explicitly whitelists pass through raw (see Data Flow Guarantee below). The client-side SDKs are not in this zone: their guarded helpers carry raw input into the runtime and only ever hand tokenized safe text to the caller's LLM step (see Inbound Trusted Path).

Being network-facing does not by itself put an endpoint in the Untrusted Zone. Trust depends on the endpoint and the direction of flow. The inbound /v1/transform ingress receives raw input by design (it is the point where protection is applied), and the authenticated restore endpoints (/v1/rehydrate, /v1/rag/answer, and scope-gated raw values from /v1/detect) return restored or raw values to a caller that holds the matching scope. Those callers are trusted recipients inside the customer's application, not the Untrusted Zone. The full set of deliberate raw-output surfaces is enumerated in Documented Raw-Egress Paths below.

Inbound Trusted Path

Raw PII travels a bounded, trusted path from the data owner into the runtime before it is protected. Naming this path in full is what makes the "raw PII never reaches the LLM" claim precise:

  1. Data owner / customer application. The application already holds its own raw PII; OGuardAI does not create it. This is the origin of the trusted path.
  2. Entry point (one of two). Either the client SDK's guarded helpers (guardedCall, guardedChat, withSession in @oguardai/sdk, and their Python equivalents) or a direct HTTPS call to /v1/transform. The guarded helpers transmit the raw input to the runtime over TLS, run the caller's LLM function on the returned tokenized safe text, then rehydrate the reply; the caller's LLM step never receives raw values. Detection and tokenization happen server-side, not in the SDK.
  3. Optional transparent proxy. When the proxy is used instead of the SDK, the client points its AI client at the proxy. The proxy terminates the request, runs the same transform, and forwards only tokenized text upstream. It is part of the Runtime Plane and sees raw PII, so it runs inside the trust boundary.
  4. TLS ingress. TLS is an operator responsibility, not an automatic property. The server can terminate TLS directly when server.tls.enabled is set with a certificate and key, but TLS is disabled by default and the server otherwise binds plaintext HTTP. In production the raw-input path must be protected by enabling server TLS or by terminating TLS at a trusted load balancer or ingress in front of the server. Plaintext HTTP must never carry raw input across an untrusted network.
  5. Runtime process. Auth, rate limiting, prompt-injection scanning, builtin regex detection, tokenization, and policy evaluation all run here on the raw text.
  6. Optional Python NER sidecar. When enabled, the runtime sends raw text to the sidecar over the internal network for NER detection. The sidecar returns spans only; it holds no keys and no session state.

Only after step 5 (and optionally 6) does tokenized text plus the sealed session blob cross the boundary into the Untrusted Zone.

+--------------------------------------------------------------+
|                      TRUSTED ZONE                            |
|                   (OGuardAI Runtime Process)                   |
|                                                              |
|   Raw PII --> Detect --> Tokenize --> Transform               |
|                                          |                   |
|   Token Map <--- Seal (AES-256-GCM) ----+                   |
|                                                              |
|   Unseal --> Repair --> Rehydrate --> Output Guard            |
|                                                              |
|   [Encryption Keys] [Session State] [Token Mappings]         |
+--------------------------+-----------------------------------+
                           |  Only tokenized text + encrypted
                           |  blobs cross this boundary
                           v
+--------------------------------------------------------------+
|                     UNTRUSTED ZONE                            |
|                                                              |
|   LLMs  |  Vector Stores  |  External Tools  |  Logs        |
|  Provider APIs  |  Agent Memory  |  Monitoring / SIEM      |
|                                                              |
|   [Sees ONLY: `{{email:e_001:7b1068662ab5}}`,               |
|               `{{person:p_001:083e771529f3}}`, ...]          |
|   [Sees ONLY: encrypted session blobs]                       |
+--------------------------------------------------------------+

Data Flow Guarantee

For a transform request under a production configuration (auth.mode set to api_key, jwt, or oidc, with prompt security and the output guard enabled), the pipeline normally performs the following stages. Rehydrate, detect, and RAG requests follow their own flows; the stages below describe the protect path, not every endpoint. Under dev auth the credential is not validated and every request is granted admin scope, and rate limiting is disabled unless configured, so the guarantees below apply to a hardened, non-dev deployment.

  1. User input enters the Trusted Zone via HTTP API (TLS terminated at the server when enabled, otherwise at a trusted load balancer or ingress).
  2. Input is authenticated, rate-limited (when rate limiting is enabled), and scanned for prompt injection attacks (when prompt security is enabled).
  3. PII is detected by builtin regex patterns and/or the optional Python NER sidecar.
  4. Detected entities are classified by type and replaced with semantic tokens ({{type:id:cap}}).
  5. Policy is evaluated per entity. The three canonical authored actions are tokenize (replace with a semantic token), redact (remove), and abstract (replace with a generalized label). Blocking is not a fourth action: it is expressed by setting on_redact: reject on a redact rule (or the policy default), which rejects the whole request instead of continuing with the value removed. Whitelisting is not an action either: it is a separate per-value list (whitelist) that passes a specific literal value through by explicit decision. Authored allow is rejected at policy load, because the protection lattice would clamp it to tokenize anyway; the whitelist is the only sanctioned raw-passthrough path. The default action tokenizes.
  6. Only tokenized text and safe metadata leave the Trusted Zone toward the LLM, unless a value is explicitly whitelisted in the policy to pass through.
  7. The LLM response enters the Trusted Zone.
  8. Tokens in the response are repaired (3-stage: strict parse, format repair, fuzzy resolution).
  9. Tokens are rehydrated to original values using the session mapping.
  10. Output guard optionally re-scans for newly generated PII (LLM hallucinations).
  11. Final output leaves the Trusted Zone.

Every detected entity is tokenized by default, and the deliberate paths that release a value are a small, enumerated set, each either a conscious policy decision or an operator opt-in. By default every detected entity is tokenized, so detected raw values stay in the Trusted Zone. A policy can whitelist a specific value, in which case that raw value is deliberately left in the output. This is a conscious policy decision, not a detection failure. The complete set of code paths that can release a value is enumerated below.

This enumeration covers the known code paths that deliberately release a value. It is not a claim that no personal data can ever reach the LLM by another route: detection is bounded by the configured patterns and NER models, so a value that no detector matches is neither tokenized nor whitelisted and passes through ordinary transform or proxy traffic as written. That is a detection-coverage limitation, documented as a non-guarantee in the Compliance Alignment section, not a deliberate egress path.

Documented Raw-Egress Paths

Raw or restored values leave the runtime only through a small, enumerated set of surfaces. They fall into two groups: deliberate restore surfaces that hand restored values back to an authorized caller inside the customer's application (this is the product's core function, not a leak), and exceptional raw-egress paths toward an upstream provider or a diagnostic, each closed by default. The policy whitelist (a per-value pass-through decision) is a third, separate category covered under the Data Flow Guarantee above.

Deliberate restore surfaces (raw or restored values returned to an authorized caller)

These endpoints exist to return real values to the application that owns the data. Each requires an authenticated principal holding the specific scope, and each is a request-response back to that caller, never a forward to an LLM or an untrusted sink.

SurfaceRecipientScope requiredPolicy / gateOutput channel
POST /v1/rehydrateCalling applicationrehydrateSession unseal (tenant + TTL checks); the envelope counter is authenticated but rehydrate is exempt from replay/high-water enforcement (see Session Lifecycle); restore mode + channel restore-overrides applied per policyRehydrateResponse.restored_text, output channel selectable (default user_output)
POST /v1/rehydrate/streamCalling applicationrehydrateSame as /v1/rehydrate, streamed as SSEStreamed restored text
POST /v1/rehydrate/fileCalling applicationrehydrateA JSON text alias of /v1/rehydrate: it accepts a RehydrateRequest and runs the same restore, not uploaded-file reconstructionRehydrateResponse.restored_text (JSON)
POST /v1/rag/answerCalling applicationragSession unseal bound to the retrieval corpus; per-channel restore rulesRagAnswerResponse.restored_answer
POST /v1/detect (with values)Calling applicationdetect plus detect_valuesinclude_values must resolve to true AND the caller must hold detect_values. Explicit include_values: true without the scope returns 403; when the field is omitted the values are suppressed by default (they default to true only under dev auth)Detected entity values in the detect response
POST /v1/batch/detect (with values)Calling applicationbatch plus detect_valuesRoute scope is batch, not detect; values are gated by detect_values with the same explicit-true 403 versus omitted-suppression rule as /v1/detectDetected entity values in the batch detect response
Rehydrated proxy responseCalling application (the proxy's own client)Proxy-authenticated clientThe proxy rehydrates the upstream LLM's tokenized reply before returning it, using the sealed session it holds for that request; output channel user_output by defaultRestored response body returned to the proxy client

The restore surfaces return values only to the scoped caller. The rehydrated proxy response is the point where the transparent proxy closes the loop: it forwards only tokens upstream, then restores the model's reply for its own client. Restored text never travels onward to the LLM or to a log sink; the trust boundary sits between the runtime and the provider, not between the runtime and its own authenticated caller.

Exceptional raw-egress paths (toward an upstream provider or a diagnostic)

Beyond the restore surfaces and the policy whitelist, these are the paths where raw request or response bytes could reach the Untrusted Zone. Each is closed by default. Most opt-ins stamp a loud response marker so the choice is never silent; the exceptions are called out per path below (the remote-image-URL opt-in forwards without a response marker, and the detector webhook is an inbound trusted-zone detector rather than an egress toward an untrusted sink).

  1. Shadow-mode comparison (diagnostic). Shadow mode runs the full pipeline and returns a comparison for tuning. It no longer returns the raw original_text. The response instead carries a redacted ShadowComparison: an original_fingerprint computed as HMAC-SHA256 of the raw input keyed by a per-process 32-byte secret generated once at startup and held only in memory (never logged, returned, or serialized), plus original_len, protected_len, and entities_masked. Keying the fingerprint prevents offline enumeration of low-entropy values (SSN, phone, order id); the fingerprint is stable only within one running process. No raw PII leaves even where shadow mode is enabled, and shadow mode is a dev-only diagnostic that validate_config rejects in non-dev. See apps/server/src/pipeline.rs (build_shadow_comparison).
  2. Proxy passthrough route (non-embedding, GET only). The transparent proxy forwards only an allowlist of provider auth, versioning, and content-negotiation headers; any other client header (Cookie, X-Forwarded-For, arbitrary X-Customer-* fields) is dropped. Each forwarded header value is PII-scanned fail-closed before it leaves, so an allowlisted header cannot smuggle PII in its value; an unscannable (non-text) header value is rejected rather than forwarded. The buffered upstream response is PII-scanned on egress and forwarded with X-GuardAI-Passthrough: scanned, or withheld fail-closed if it carries PII. The GUARDAI_PROXY_ALLOW_UNSCANNED_PASSTHROUGH opt-in (default off) forwards the response raw, flagged X-GuardAI-Passthrough: unscanned. See apps/proxy/src/passthrough.rs.
  3. Proxy embeddings path (/v1/embeddings, POST). The request query string and every non-input request field is scanned recursively (string values, object keys, and numeric leaves), a non-string user field is rejected, and the input receives the same field-by-field masking the chat path uses. Non-UTF-8 request bodies fail closed rather than being lossily decoded and forwarded. A 2xx upstream response is validated against the exact embeddings response shape before being forwarded unscanned; any non-conforming or error body that could echo PII is sanitized. Pre-tokenized numeric-array input cannot be scanned, so it is rejected unless the GUARDAI_PROXY_ALLOW_PRETOKENIZED_EMBEDDINGS opt-in (default off) is set, in which case a loud pretokenized_unscanned marker is returned. See apps/proxy/src/passthrough.rs (transform_embedding_input).
  4. Proxy remote image URLs (GUARDAI_PROXY_ALLOW_REMOTE_IMAGE_URLS, default off). A data: image URI is OCR-scanned like inline bytes. A remote image URL is rejected fail-closed by default, because the proxy cannot fetch and OCR the remote pixels, so PII rendered inside the image would pass unseen. When the operator sets this opt-in, the remote URL is forwarded to the provider after scanning only the URL string itself (not the image content), so PII in the pixels is not caught. Unlike the embeddings and passthrough opt-ins, this path stamps no response marker; the operator log warning at startup is the only signal. See apps/proxy/src/image_guard.rs.
  5. Configured HTTP detector webhook (detector.webhook, absent by default). When an operator configures a webhook detector, the runtime posts the raw request text to that HTTP endpoint for detection. This is an inbound detector, not an egress toward an untrusted sink: like the Python NER sidecar it receives raw text to find entities, so it sits inside the trust boundary and must stay on an internal or otherwise trusted network. The client validates the URL fail-closed (https or a loopback host required; cleartext http to a non-loopback host demands an explicit allow_insecure_http operator opt-in, a config flag the endpoint validator honors regardless of auth mode rather than one config validation rejects outside dev), and mTLS material requires TLS. See crates/detector-client/src/webhook.rs.

Side-Channel Protection

OGuardAI takes explicit measures to prevent PII leakage through side channels:

  • Logging: All tracing calls are verified to contain zero raw PII. Logs record only entity types, counts, policy names, timing metrics, and token IDs. The raw value is never present.
  • Error messages: All error types implement Display with messages that contain diagnostic context (error codes, entity types, counts) but never raw entity values.
  • Debug output: Token, TokenMap, and EntitySpan types implement custom Debug that redacts original_value fields. Even a developer debugging with {:?} formatting will not see raw PII.
  • Temp files: OCR temporary files use random file names. The input image tempfile is RAII-managed and removed on drop. Tesseract writes its .tsv output to a separate path that the code deletes explicitly on most return paths, but the cleanup is best-effort rather than an unconditional RAII drop for that file. The timeout, non-zero-exit, and oversize-output rejections each delete the .tsv before returning, and the normal success path reads it and then deletes it. Two branches return without removing it: a failure while polling the subprocess (try_wait error) and a failure reading the .tsv back. On the success path the delete itself is logged and ignored if it fails. So a .tsv artifact can survive on disk after one of those error branches or a failed unlink, and an operator relying on this must treat the OCR temp directory as needing periodic cleanup rather than assume every run removes its own file.
  • HTTP error responses: 4xx and 5xx responses contain error descriptions and error codes. Request content is never echoed back in error responses.
  • Metrics: Exported metrics include request counts, latency histograms, and entity type counters. No PII values appear in metric labels or values.

2. Encryption & Session Model

Sealed Sessions (Default Backend)

The sealed session backend is OGuardAI's default and recommended mode for stateless horizontal scaling. It operates as follows:

  • Algorithm: AES-256-GCM (authenticated encryption with associated data), per NIST SP 800-38D.
  • Key: 32-byte key derived from a configurable secret. The secret is set via session.secret in oguardai.yaml or the GUARDAI_SESSION_SECRET environment variable.
  • Nonce: 12-byte random nonce generated per seal operation. Each seal uses a fresh nonce, ensuring that identical payloads produce different ciphertext.
  • Authentication: 16-byte GCM authentication tag. The tag provides both confidentiality and integrity verification. If the tag verification fails during unseal, the entire blob is rejected (fail-closed).
  • Payload: JSON-serialized token map containing: token ID to original value mapping, entity metadata (type, gender, formality, language), and session metadata.

Sealed Envelope Structure

{
  "v": 4,
  "kid": 0,
  "sid": "uuid-v4",
  "tid": "acme-corp",
  "pid": "gdpr-strict",
  "ctr": 1,
  "exp": 1713142800,
  "cat": 1713139200,
  "lang": "de",
  "aud": 0,
  "cls": "",
  "ks": 1,
  "nonce": "<base64-12-bytes>",
  "ct": "<base64-encrypted-payload>",
  "tag": "<base64-16-bytes>"
}

Alongside the payload fields, cat is the session creation timestamp, aud is the audience byte (a Standard session versus a per-chunk RAG sub-session), cls is the classification label (empty for non-RAG sessions), and ks is the key scheme: 0 for a global-key blob and 1 for a tenant-bound blob whose AES key is HKDF-derived per tenant from the ring key, so one tenant's key compromise cannot decrypt another tenant's sessions. All are bound in the authenticated encryption AAD, so tampering with any of them fails the unseal. An envelope whose version is not the current one is rejected fail-closed rather than read under an older layout.

The envelope is JSON-serialized and returned to the client as an opaque string in the session_state field. Its binary fields (the nonce, the ciphertext ct, and the authentication tag) are base64-encoded inside that JSON; the surrounding structure is plain JSON, not a single base64 blob.

Key Management

  • Key rotation: The runtime seals through a key ring indexed by the kid (key ID) field in the sealed envelope. By default the ring holds a single key (kid 0), so a simple secret change is a brief disruption. Configuring multiple keys enables zero-downtime rotation: new sessions seal under current_kid while sessions sealed under an older kid keep decrypting until that key is removed (typically after one session TTL). See the key rotation runbook for the operational procedure.
  • Tenant isolation: Session blobs include a tenant_id binding (AAD), and a tenant-scoped session's AES key is HKDF-derived per tenant from the ring key (envelope ks=1), so one tenant's derived-key compromise cannot decrypt another tenant's sessions. Cross-tenant blobs are rejected at unseal time.
  • Default key id: New blobs seal under the single-key default of key ID 0, and the kid is always written into the envelope. A sealed envelope that omits kid is rejected fail-closed, never silently read as key ID 0.
  • Fail-closed behavior: Wrong key, tampered data, or an expired session produces a GUARDAI_SESSION_EXPIRED error; a cross-tenant blob is rejected with GUARDAI_POLICY_DENIED. The system never returns garbage data, partial decryption, or guessed values.

Session Lifecycle

  1. Created during transform: contains the retained, recoverable token-to-value mappings after policy evaluation. Entities the policy redacts are purged before sealing (their values are not restorable), so the sealed session holds only the tokenized and other recoverable mappings, not every detected value.
  2. Carried by the client as an opaque encrypted blob (sealed backend) or referenced by session ID (memory backend, or the Redis backend which encrypts each session at rest).
  3. Unsealed during rehydrate: tenant ID and expiry are validated before decryption proceeds.
  4. TTL enforced: Configurable (default 3600 seconds). Expired sessions are rejected with a clear error.
  5. Request counter / replay rejection: A counter is sealed into the envelope. For transform continuations it is enforced server-side: a store records the highest counter seen per session id and rejects a continuation whose counter does not strictly exceed it, so a captured session_state cannot be replayed to re-continue a superseded session (backend replay_backend: memory default per-replica, or redis shared across replicas; a redis replay backend advertises a multi-replica deployment, which also requires revocation_backend: redis). Rehydrate (idempotent) and RAG context (multi-retrieval fan-out) are exempt by design and remain TTL-bounded bearer tokens, bounded by tenant-verified unseal, TLS, and (for RAG) corpus binding. The memory backend has a cold-start window on restart; Redis closes it in HA.
  6. No server-side session state (sealed backend): the client is the sole custodian of the session blob, so the server stores no plaintext session or token-map state between requests. This is scoped to session storage, not to all state: the replay backend keeps a non-PII per-session-id high-water counter (memory or Redis), and idempotency and audit backends may retain their own records. What the sealed backend avoids is server-side token-map and session storage, not every byte of per-request bookkeeping.

Alternative Session Backends

BackendDescriptionUse Case
Sealed (default)AES-256-GCM encrypted blob, client-carriedStateless deployments, horizontal scaling
MemoryIn-process hash mapDevelopment and testing only
Redis sessionExternal Redis store, encrypted at rest, TTL-based expiryMulti-instance deployments with shared session state. Requires replay_backend: redis and revocation_backend: redis; server startup rejects a redis session backend without redis replay and fails closed without redis revocation

3. Authentication & Authorization

Auth Providers

OGuardAI supports four authentication modes, configured via auth.mode in oguardai.yaml:

ProviderUse CaseConfiguration
DevDevelopment and testingAll scopes granted, no validation. Startup warning emitted.
API KeyService-to-service authenticationStatic keys configured in oguardai.yaml or environment variables. Keys compared using constant-time comparison (subtle::ConstantTimeEq) to prevent timing attacks.
JWTToken-based authenticationHS256/384/512 (HMAC) signatures, configurable issuer. Tokens validated on every request.
OIDCFederated identity providersAsymmetric bearer tokens verified against a remote JWKS. Pinned algorithm allowlist (RS256/384/512, ES256/384, PS256/384/512) rejects alg:none and HMAC; issuer and audience pinned; a tenant claim is required; JWKS is cached and fetched fail-closed.

Scope Model

Authorization is scope-based. Each authenticated principal has one or more scopes that determine which endpoints they can access:

ScopeEndpoints
Transform/v1/transform, /v1/transform/stream, /v1/transform/file, /v1/transform/image, /v1/redact/image, /v1/entity-types, /v1/patterns
Rehydrate/v1/rehydrate, /v1/rehydrate/stream, /v1/rehydrate/file
Detect/v1/detect
Policy/v1/evaluate-policy
Batch/v1/batch/transform, /v1/batch/detect
Rag/v1/rag/ingest, /v1/rag/ingest/batch, /v1/rag/query, /v1/rag/context, /v1/rag/answer
Revoke/v1/revoke, /v1/revoke/bulk, /v1/rag/delete (within the caller's tenant)
Global RevokeElevation on Revoke an untenanted caller needs to write an unscoped revocation that suppresses a value at rehydration for every tenant; Admin implies it
Session/v1/sessions/status, DELETE /v1/sessions
AdminAll endpoints, plus /v1/diagnostics, /v1/admin/policy/validate, /v1/admin/policy/reload, /v1/revocations/count, /metrics

The table covers every scope-gated route. /v1/health and /v1/capabilities require authentication (when enabled) but no specific scope, and /livez and /readyz are unauthenticated probes. The detect_values scope gates receiving raw detected values from /v1/detect and /v1/batch/detect rather than a route of its own. See apps/server/src/lib.rs for the full route set and each handler's require_scope call for the authoritative scope. The Admin scope implicitly grants all other scopes. This enables a least-privilege model where services that only need to transform data do not receive rehydrate permissions.

Security Properties

  • All API endpoints require authentication unless explicitly configured otherwise.
  • Authentication runs in middleware, which resolves the principal and its scopes; each route handler then calls require_scope for the scope that route needs. Scope authorization is enforced at the handler, not independently re-checked in the runtime/service kernel.
  • API keys are never logged, even partially. Only the key name or a hash appears in audit events.
  • Constant-time comparison is used for all secret comparisons (API keys, JWT signatures) to prevent timing side-channel attacks.

4. Multi-Tenant Isolation

OGuardAI provides multi-tenant isolation at multiple layers:

Session Isolation

  • Tenant ID is cryptographically bound to the sealed session blob (included in the authenticated encryption envelope).
  • Attempting to rehydrate a session blob from tenant A using tenant B's context produces a PolicyDenied error. Cross-tenant blob reuse is prevented by the cryptographic binding.
  • Per-tenant encryption keys provide cryptographic separation between tenants: each tenant-scoped session's AES key is HKDF-derived per tenant from the ring key, so a blob sealed for one tenant cannot be decrypted under another tenant's key.

Policy Isolation

  • Each tenant can have a different default policy, configured under tenants.{id}.default_policy in oguardai.yaml.
  • Policies live in one global catalog, and a request may name a policy override. Tenant isolation of policy selection is therefore conditional: configure a non-empty tenants.{id}.allowed_policies list and any request-level override for that tenant is validated against it (a policy outside the list is rejected with PolicyDenied). With no such list configured, the tenant's default applies but a request can still select any policy in the catalog, so the "one tenant's policy never affects another's requests" property holds only when the allowlist is set.

Rate Limit Isolation

  • Per-tenant rate limits are independently configured (tenants.{id}.rate_limit.requests_per_second, tenants.{id}.rate_limit.burst_size).
  • One tenant's traffic spike does not affect another tenant's rate limit budget.

Audit Isolation

  • All audit events are tagged with the tenant ID.
  • Log entries include tenant context for SIEM filtering and compliance reporting.

5. Prompt Security

OGuardAI includes a dedicated prompt security layer (guardai-prompt-security crate) that defends against prompt injection and data extraction attacks:

Input Scanning

A built-in, immutable baseline of regex rules detects common attack vectors. The baseline is multilingual (English plus German, French, Spanish, Italian, Portuguese, and Dutch), and deployment config can only add rules on top, never remove one. The rules cover these categories:

  1. Extraction attempts: Patterns that try to make the LLM reveal token mappings or system instructions.
  2. Instruction overrides: Attempts to inject "ignore previous instructions" or similar phrases.
  3. Indirect extraction: Attempts to discover what is behind, underneath, or hidden in a token or placeholder.
  4. Output manipulation: Attempts to make the LLM output, print, or return raw/original/unmasked data.
  5. Role-play injection: Attempts to make the LLM adopt a role that bypasses safety measures.
  6. System prompt extraction: Attempts to exfiltrate the system prompt or preamble.

Configurable Actions

When a prompt security pattern matches, the following actions are available:

ActionBehavior
warnLog the detection but permit the request (monitoring mode)
stripRemove the malicious sentences from the input; remainder is processed
blockReject the entire input with a PolicyDenied error

System Preamble

The transparent proxy can inject a token-opacity system preamble into the LLM-facing prompt, but only when the operator sets GUARDAI_PROXY_SYSTEM_PREAMBLE (opt-in, off by default so it does not change token usage or model output for existing deployments). The core server does not inject a preamble; direct API or SDK integrations that want this instruction must add equivalent wording to their own system prompt. When enabled, the preamble instructs the model to:

  • Treat {{type:id:cap}} tokens as opaque placeholders.
  • Never attempt to guess, decode, or reveal the values behind tokens.
  • Preserve tokens exactly as-is in the model's output.
  • Never generate new PII that was not present in the input.

The hard protections (tokenization before the prompt leaves the runtime, and the output guard on the response) do not depend on the preamble. Tokenization is the default protect path, and the output guard is enabled by default and enforced outside dev: a non-dev startup treats a disabled output guard as a fatal config error, while dev only warns and lets it be turned off. So in any hardened, non-dev deployment both are on regardless of the preamble.

Output Guard (Second-Pass Protection)

After the LLM responds, the output guard optionally performs a second detection pass on the LLM's output to catch:

  • Hallucinated PII: The LLM may generate realistic-looking but fabricated personal data (names, emails, phone numbers) that was not in the original input.
  • Token leakage: The LLM may partially reveal or reconstruct information from token metadata.

Per-entity-type actions are configurable:

ActionBehavior
AllowAllow the entity through unchanged
WarnLog a warning but pass the entity through unchanged
MaskReplace the hallucinated PII with a type label like [EMAIL]
BlockReject the entire output if hallucinated PII is detected

6. Detection Model

Builtin Detectors (Rust Regex)

The builtin detection engine provides 30+ pre-compiled regex patterns covering:

CategoryEntity Types
ContactEmail, phone (international formats), URL
FinancialIBAN (format/prefix regex, no checksum validation), credit card (Luhn algorithm validation), SSN
IdentityPassport numbers, health IDs, German tax ID (Steuerliche Identifikationsnummer), German social security number
PersonalDate of birth, physical address patterns
BusinessCustomer ID, order number
NetworkIPv4, IPv6 addresses

All patterns are pre-compiled at initialization using OnceCell / lazy_static. No regex compilation occurs on the request path.

Advanced Detectors (Python NER Sidecar)

The optional Python detector service provides NER-based detection for entities that regex cannot reliably capture:

  • GLiNER (zero-shot): Detects person names, company names, and locations without language-specific training data. Supports 30+ languages.
  • spaCy models: Language-specific NER models for higher accuracy on supported languages.

The Python sidecar communicates with the Rust runtime via HTTP (internal network only). It is deployed as a separate container.

Graceful Degradation

If the Python NER sidecar is unavailable (network failure, startup delay, crash):

  • The runtime falls back to builtin regex detection only.
  • Readiness reflects it, not liveness: /v1/health intentionally keeps reporting the detector healthy so an optional sidecar cannot flip a liveness probe, while /readyz probes the sidecar (the authenticated /authz endpoint when a detector key is configured) and returns 503 when NER is required but the sidecar is unreachable, so the pod is taken out of rotation rather than advertising NER it cannot use.
  • A warning is logged.
  • Requests do not fail due to sidecar unavailability, with one deliberate exception: a policy that sets detection.required_for for an NER-backed type fails closed rather than under-detecting in silence. Everything else degrades gracefully.

Detection Confidence

Every detection result includes a confidence score (0.0 to 1.0):

  • Builtin regex patterns: confidence is a fixed value per pattern, not raised by validation (e.g., email = 0.95, IBAN format/prefix regex = 0.9 with no checksum validation, phone pattern = 0.8). A pattern that also carries a post-regex validator uses the validator to keep or drop the match, not to change the reported confidence. Nearby context words can add a bounded boost on top of the base value.
  • Python NER: confidence is model-reported.
  • A configurable threshold (min_confidence, unset by default so all detections are kept) filters low-confidence detections; raising it trades recall for precision.

7. Operational Security

Rate Limiting

  • Configurable per-tenant token bucket rate limiter.
  • Parameters: requests_per_second and burst_size.
  • Disabled by default (startup warning emitted when disabled).
  • Returns HTTP 429 with GUARDAI_RATE_LIMITED error code when exceeded.

Startup Security Checks

On startup, OGuardAI inspects its own configuration. In dev mode the checks are advisory: they log warnings and startup proceeds. Outside dev mode (api_key, jwt, oidc) most of them are fatal production validations that abort startup, so a hardened deployment cannot silently run with an insecure default.

CheckConditionNon-dev outcome
Session secretUsing the placeholder CHANGE_ME_32_BYTE_SESSION_SECRET (or another known example value)Fatal
Prompt securityPrompt security layer is disabledFatal
Prompt security actionAction set to warn (detects but passes injection through)Fatal
Output guardOutput guard is disabledFatal
Rate limitingRate limiting disabled, or a zero rate/burstFatal
Audit durabilityNon-durable or non-strict audit trail without audit.allow_ephemeralFatal
Shadow modeshadow_mode enabled (a dev-only diagnostic)Fatal
Auth modeRunning in dev mode (all requests have admin access)Advisory (this is the dev mode itself)

The fatal checks abort startup in non-dev mode so an operator cannot ship with them unmet. The advisory items log a warning and continue. In dev mode all of the above are advisory only, which is why dev is for development and testing, not production.

Diagnostics

An admin-only /v1/diagnostics endpoint reports feature status:

  • Active session backend
  • Detector mode and sidecar connectivity
  • Loaded policies
  • Feature flags (prompt security, output guard, rate limiting)
  • No secrets, keys, or PII are included in diagnostic output.

Structured Logging

  • All logging uses the tracing crate with structured JSON-compatible output.
  • Application log events carry whatever structured fields the emitting span attaches; the fields are not uniform across every event. The consistent, machine-parseable per-operation record is the audit event described below, not an arbitrary log line.
  • Log output is compatible with standard SIEM systems (Splunk, Elasticsearch, Datadog).
  • Log levels are strictly enforced: debug for development details, info for significant actions, warn for recoverable issues, error for failures requiring attention.

Audit Events

Audit emission is per route rather than exactly one event for every call. A single transform, detect, rehydrate, RAG ingest, query, context, or answer operation emits one structured AuditEvent. RAG delete is the exception: one call emits two RagDelete events, a write-ahead intent record before the revocation table is mutated and a completion (or, on a persistence failure, a failure) outcome record after the durable persist, so a dropped persist can never leave only a success-shaped record behind. Batch transform emits one per-item Transform event (no separate BatchTransform event), so an empty batch transform emits none. Batch detect instead emits a single aggregate BatchDetect event, recorded even for an empty batch so a zero-item detect is still durably logged under strict audit. Failure paths emit a failure event only for the security-relevant error categories (policy denial, output-guard block, token-repair failure, session expiry, session replay, session rollback, replay-store outage, detection failure, and auth failure); plain input-validation and internal errors are deliberately not audited, and a request rejected by route validation before it reaches the audit step produces no audit event at all. The fields of an AuditEvent are:

  • timestamp_unix and event_type (the operation kind).
  • tenant_id (present in multi-tenant mode).
  • session_id, emitted only as a one-way SHA-256 fingerprint, never the raw session id.
  • policy_applied: the name of the policy that ran. There is no separate policy-version field.
  • entity_types: the distinct entity types seen (e.g., ["email", "phone"]), types only, never values. This is a de-duplicated list, not a per-type count map.
  • Aggregate counts: entity_count (total detected), entities_blocked, and entities_tokenized. Counts are aggregate across the request, not broken down per type.
  • duration_ms: total pipeline time.
  • detector_mode, and the output_guard_triggered / prompt_security_triggered flags.
  • trace_id, when present, emitted only as a one-way fingerprint for correlating a transform with its rehydrate.
  • failure, on failure events only: a stable GUARDAI_* error code plus a closed reason enum, never a free-form message.
  • Reserved GDPR Art. 30 context fields (purpose, legal_basis, data_category). The GdprContext struct defines them as controlled labels (legal_basis is a closed set, not a free-form string) and each is omitted from the serialized event when empty. There is no policy or config wiring that populates them yet: production constructors build GdprContext::default(), so today these fields are always absent. They are a forward-looking slot, not currently emitted values.

Audit events never contain raw PII values.

By default, audit events are emitted as structured log records via tracing on the guardai::audit target, and durability, retention, and tamper-resistance depend on the operator's log pipeline (SIEM, log sink, WORM storage). The optional file audit backend additionally maintains a local append-only store with a per-tenant HMAC hash chain: each record links to the previous record of the same tenant, so editing a record, reordering records, or deleting a record from the interior of a tenant's chain breaks verification and is detectable, and one tenant's records verify and export independently of another's. One case the chain alone cannot catch is tail truncation: dropping the most recent records leaves a shorter but internally valid chain. Detecting that a valid prefix is missing its tail requires an external anchor: a periodically recorded expected-tail checkpoint, or a WORM or append-only sink the writer cannot rewrite. For multi-region durability or tamper-evidence against tail truncation, route the audit stream to a durable, access-controlled sink with such an anchor.


8. Deployment Security

Self-Hosted Model

OGuardAI is designed for self-hosted deployment. In this mode:

  • Data residency: All core detection, tokenization, sealing, and rehydration run within the customer's infrastructure. Raw detected values stay inside the trust boundary. In an air-gapped configuration (local models, no upstream provider, and no OIDC or remote JWKS dependency), no external service is contacted at all.
  • External calls depend on configuration: OGuardAI itself ships no subprocessor, but several configured features reach outside the process, and none is required for the core protect and restore path. They are:
    • Transparent proxy to the upstream AI provider. Its whole purpose is to forward tokenized requests to the provider, so tokenized prompts and network metadata leave the network toward it. Raw values normally do not, with two deliberate exceptions covered above: a policy whitelist value is forwarded raw by design, and the GUARDAI_PROXY_ALLOW_REMOTE_IMAGE_URLS opt-in forwards a remote image URL whose pixels were never scanned.
    • OIDC JWKS fetch. When auth.mode is oidc, the server fetches the identity provider's JWKS over HTTPS to verify tokens.
    • HTTP detector webhook. When detector.webhook is configured, raw request text is posted to that trusted-zone detection endpoint (keep it on an internal network; see Documented Raw-Egress Paths).
    • Notification webhook. When notifications is configured, the server delivers fire-and-forget push notifications to the configured webhook.
    • OTLP trace export. When OpenTelemetry export is configured, spans (no raw PII) are sent to the configured collector.
    • Python NER sidecar. When enabled, raw text is sent to the sidecar over the internal network for detection (inside the trust boundary).
  • No phone-home: The server does not contact any external service for licensing, telemetry, or updates.

Container Hardening

The production Docker images follow container security best practices:

MeasureImplementation
Non-root executionUSER guardai, a system user created with useradd -r (no fixed UID assigned in the image); the Helm chart pins runAsUser: 1000 / fsGroup: 1000 in its security context
Read-only filesystemreadOnlyRootFilesystem: true in Kubernetes security context
Minimal base imagedebian:bookworm-slim runtime image (no build tools)
Dropped capabilitiescapabilities.drop: [ALL], no Linux capabilities granted
No privilege escalationallowPrivilegeEscalation: false
Health checksBuilt-in health check endpoint at /v1/health

Network Security

  • Single port: The server exposes a single port (default: 3000).
  • TLS termination: Designed for TLS termination at the load balancer or ingress controller. The server handles plaintext HTTP within the cluster network.
  • Detector sidecar auth: The Python NER sidecar is protected by a shared secret (GUARDAI_DETECTOR_API_KEY, presented as the X-Detector-API-Key header); when configured, the runtime fails closed if the sidecar rejects the request. Keep the sidecar on an internal network regardless.
  • CORS: In dev auth mode the server uses permissive CORS, suitable for development and API-first deployments. In non-dev modes (api_key, jwt, oidc) the server builds an explicit allowlist from server.allowed_origins; only those origins are permitted, and cross-origin requests are rejected when the list is empty. For browser-facing production deployments, set server.allowed_origins (or restrict origins at the reverse proxy or ingress layer).
  • Internal sidecar communication: The Python detector sidecar communicates over the internal cluster network on port 9090. This traffic does not need to be exposed externally.

Secrets Management

  • Session encryption secrets are injected via environment variables or Kubernetes secrets. They are never stored in configuration files committed to version control.
  • The Helm chart supports existingSecret references for integration with external secret managers (Vault, AWS Secrets Manager, etc.).
  • The GUARDAI_SESSION_SECRET environment variable takes precedence over the session.secret config field.

9. Supply Chain Security

Language Choice

OGuardAI's core runtime is written in Rust, which provides:

  • Memory safety in safe Rust: Safe Rust eliminates whole classes of memory-safety defects (buffer overflows, use-after-free, data races) at compile time, with no garbage collector. This is not an absolute guarantee for the whole process: the server uses a small amount of unsafe (for example a libc::setrlimit call to cap address space) and links native libraries, and those unsafe blocks and native dependencies remain manual review boundaries rather than compiler-checked ones.
  • No managed runtime: There is no JVM, interpreter, or GC-based runtime framework. The binary is not fully static, though: it depends on the system libc and links native TLS (the server image builds against OpenSSL via libssl-dev), so it carries native library dependencies rather than being pure self-contained Rust.
  • Predictable performance: No GC pauses and no JIT warmup, so the steady-state latency profile is not perturbed by runtime housekeeping. This is not a benchmarked guarantee of identical first-request latency: lazy initialization, cold caches, external services, and NER model load can still make an early request slower than a warm one.

Dependency Management

  • Minimal crate dependencies: The dependency tree is auditable via cargo audit.
  • Pinned versions: All dependency versions are pinned in Cargo.lock. No floating version ranges in production dependencies.
  • CI auditing: cargo audit runs in CI. Known vulnerabilities block merge.
  • License compliance: All dependencies are checked for license compatibility.

Native Code

  • Native library dependencies: The server links the system libc and native TLS (OpenSSL in the standard server image). It is not pure, dependency-free Rust; these are the native surfaces to include in a supply-chain review.
  • OCR: The OCR integration (guardai-ocr) is compiled into the server binary, and the production server image installs the Tesseract CLI (tesseract-ocr plus English, German, and Arabic language packs). OCR is invoked only when an image text-extraction request is made, so it is optional at runtime, but it is present in the default server build and image rather than excluded from it. When invoked, OCR shells out to the Tesseract subprocess; its temporary files use random names. The input tempfile is RAII-managed, and Tesseract's separate .tsv output is deleted explicitly on the normal and handled-error paths (see Side-Channel Protection).
  • Python sidecar: The optional NER detector uses Python with spaCy/GLiNER. It runs as a separate process and communicates via HTTP only. It has no access to the Rust runtime's memory or encryption keys.

10. Token Safety Guarantees

Token Format

The canonical token format is {{type:id:cap}} (e.g., {{email:e_001:7b1068662ab5}}). The third segment is a per-token capability suffix (12 hex characters in the default per-request scheme) that rehydrate matches exactly; a capless or wrong-cap token never resolves (fail closed). This format is:

  • Deterministic: The same entity in the same session always produces the same token. Token assignment is ordered by (start, end, type, normalized_value) to ensure determinism regardless of detection order or parallelism.
  • Non-reversible from format: The token ID (e_001) and capability suffix reveal nothing about the original value. Only the encrypted session mapping can resolve it.
  • Type-preserving: The type prefix (email, person, phone) supplies an entity-type signal that may help the model preserve grammatical context, more than a generic [REDACTED] placeholder would. It does not guarantee grammatically correct output; as noted in the Executive Summary, output quality depends on the model, prompt, and policy.

Token Robustness

LLMs frequently corrupt token syntax in their output (dropped braces, added spaces, case changes). OGuardAI employs a 3-stage repair pipeline:

  1. Strict parse: Exact {{type:id:cap}} match.
  2. Format repair: Fix common corruptions (missing braces, extra whitespace, case normalization).
  3. Fuzzy resolution: Levenshtein distance matching for severely corrupted tokens. Single-candidate-only policy: if ambiguous, the token is left unresolved rather than guessed.

Fail-Safe Properties

  • Hallucinated tokens: Tokens not present in the session mapping are logged and left unresolved. They are never fabricated or guessed.
  • Expired sessions: Produce clean GUARDAI_SESSION_EXPIRED errors. No partial decryption or fallback behavior.
  • Ambiguous resolution: When fuzzy matching finds multiple candidates, the token is left as-is. No guessing.
  • Restore values: Come ONLY from the session mapping. OGuardAI never fabricates, infers, or generates PII.

11. Compliance Alignment

The controls below are alignments that can support these frameworks, not compliance itself. OGuardAI is not certified or attested under GDPR, HIPAA, or SOC 2, and using it does not by itself make a deployment compliant. Detection covers only the entity categories the operator configures (builtin patterns, custom patterns, and NER models); it cannot, by nature, catch every piece of personal data in every context. Encryption in transit depends on the operator enabling TLS. Validate coverage and configuration against your own data and your own legal and audit requirements.

GDPR (General Data Protection Regulation)

  • Article 4(1), Personal Data: OGuardAI detects and protects the personal-data categories it is configured to detect (names, email addresses, phone numbers, national identifiers, and similar). It does not detect every category of personal data GDPR defines; coverage is bounded by the configured patterns and models.
  • Article 5(1)(f), Integrity and Confidentiality: AES-256-GCM encryption of session state provides confidentiality and, via the GCM authentication tag, integrity for the sealed blob. Confidentiality in transit additionally requires the operator to enable TLS (see Deployment Security); it is not automatic.
  • Article 25, Data Protection by Design: Tokenization is the default action, so the protect path is active without extra configuration. Production hardening (non-dev auth, TLS, enabled guards) is still the operator's responsibility.
  • Article 32, Security of Processing: Encryption at rest (sealed sessions), access control (scoped authentication), and audit logging support Article 32 measures.

HIPAA (Health Insurance Portability and Accountability Act)

  • Safe Harbor De-identification: OGuardAI detects and masks the HIPAA Safe Harbor direct identifiers it is configured to detect (names, dates, SSN, medical record numbers, account and certificate numbers, and similar) through its entity type system, custom_patterns, and the policy engine, while preserving clinical context that is not a direct identifier. These are HIPAA-aligned controls, not a certified de-identification service: no detector catches every identifier in every context, so validate coverage against your own corpus before relying on it.
  • Technical Safeguards: Encryption (AES-256-GCM), access controls (scoped auth), and audit controls (structured logging) align with HIPAA technical safeguard requirements.

SOC 2 Type II

  • CC6.1, Logical Access: Scoped authentication with API keys or JWT tokens.
  • CC6.6, External Threats: Prompt security layer defends against injection attacks.
  • CC6.7, Data Transmission: Session state is encrypted at rest (AES-256-GCM). Encryption in transit requires the operator to configure TLS (server TLS or a TLS-terminating ingress); it is not enabled by default.
  • CC7.2, System Monitoring: Structured logging and audit events provide continuous monitoring capabilities.

12. Threat Model Summary

ThreatMitigation
LLM sees raw PIITokenization replaces detected PII before the LLM receives input, unless a policy explicitly allows an entity through
LLM hallucinates PIIOutput guard detects and masks/blocks newly generated PII
Session blob tamperingAES-256-GCM authentication tag detects any modification
Cross-tenant data accessTenant ID bound to sealed blob; wrong tenant rejected
Session replayRejected for transform continuations: a per-session-id strict-monotonic counter store (replay_backend: memory or redis) rejects a superseded continuation blob. Rehydrate and RAG context are exempt by design (idempotent / fan-out) and remain TTL-bounded; keep TTLs short and protect blobs in transit (TLS). Memory backend has a cold-start window; Redis closes it in HA
Prompt injectionMultilingual input scanner (15 built-in rules across 6 categories) with configurable block/strip/warn actions
Token extraction from LLMSystem preamble instructs LLM to treat tokens as opaque
PII in logsCustom Debug impls redact values; no raw PII in any log path
PII in error messagesAll error types verified to contain no raw entity values
Timing side-channel on authConstant-time comparison for all secret comparisons
Expired session exploitationTTL enforcement with fail-closed behavior
Key compromiseRotate via the key ring: add a new key as current_kid so new sessions use it, then remove the compromised key to reject blobs sealed under it; per-tenant derived keys keep one tenant's derived-key compromise from decrypting another tenant's sessions

Appendix A: Configuration Security Checklist

Before deploying OGuardAI in production, verify the following:

  • session.secret is set to a unique 32-byte secret (not the default placeholder)
  • auth.mode is set to api_key, jwt, or oidc (not dev)
  • prompt_security.enabled is true
  • output_protection.enabled is true
  • rate_limit.enabled is true with appropriate per-tenant limits
  • TLS is configured at the load balancer / ingress level
  • The Python detector sidecar (if used) is on an internal network only
  • Container runs as non-root with read-only filesystem
  • GUARDAI_SESSION_SECRET is injected via Kubernetes secret or secret manager
  • CORS allowed origins are restricted at the reverse proxy or ingress layer for browser-facing deployments
  • Audit logs are forwarded to a SIEM system
  • cargo audit / pip-audit / npm audit pass with zero known vulnerabilities

This is the canonical security whitepaper. A stub exists at docs/security-whitepaper.md for repository navigation.