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:7b1068662ab5aa11bb22cc33dd44ee55}}). 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:7b1068662ab5aa11bb22cc33dd44ee55}}`,               |
|               `{{person:p_001:083e771529f311aa22bb33cc44dd55ee}}`, ...]          |
|   [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; because it lets data cross the boundary unscanned, it is dev-proxy-only and refused at startup on a non-dev proxy unless GUARDAI_PROXY_DEV=1. 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; because it lets data cross the boundary unscanned, it is dev-proxy-only and refused at startup on a non-dev proxy unless GUARDAI_PROXY_DEV=1. 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 requires both an explicit allow_insecure_http operator opt-in and dev auth mode, and is rejected fail-closed in every non-dev profile even when the opt-in is set), 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 safe entity types, counts, keyed policy fingerprints, 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 coherence: The tag authenticates origin, not internal consistency. An authenticated payload whose token map is internally inconsistent (a map key that disagrees with its token id, an id without its type prefix, or a display token not derived from its fields) is refused at unseal like an expired session, with only the violated invariant recorded in the log. Seal enforces the same check and refuses to issue such a blob.
  • 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": 6,
  "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 check is exact equality in both directions: a runtime accepts only the version it emits, so an older node can never read a newer blob by ignoring the fields it does not know about, and a newer node never reads an older blob under assumptions that no longer hold. A release that bumps the envelope version therefore invalidates every in-flight session; see the version upgrade runbook for the drain procedure.

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

The guard has to tell a value it restored itself apart from a value the model produced, or every authorized restore would be re-masked. It does that positionally: rehydration records the byte ranges its authorized restores wrote, and those ranges are the only exemption. They subtract by byte rather than exempting a whole detection, so a detection that straddles the boundary keeps its unauthorized bytes under enforcement. When the output detector returns a single span covering both a restored name and a name the model invented next to it, the invented part stays subject to the configured action. A structurally malformed range is discarded before subtraction and can never exempt anything, a revoked value records no authorized range at all, and the exemption is never by value: the same value regenerated anywhere else in the output is still masked or blocked.

A block and a scanner outage are separate outcomes and must not be read as one. Block returns 422 GUARDAI_OUTPUT_BLOCKED: the content was scanned and refused, so resubmitting the same content will be refused again. When the scan itself cannot run, the response is 503 GUARDAI_OUTPUT_GUARD_UNAVAILABLE: the content was never judged, which is a deployment fault the caller may retry once the scanner is reachable. A policy whose detection.required_for names an NER-backed type needs detector.scan_output_with_ner on with detector.mode both or advanced and a reachable NER detector; without that, every response under that policy takes the 503 path regardless of content.


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

Every built-in pattern is compiled when the detector is constructed at startup: BuiltinDetector::new walks the registry and forces each pattern's OnceLock, so no regex compilation is left for 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

Degradation is scoped to detector.mode: both, where NER is optional. In advanced mode NER is mandatory: a sidecar outage fails the request closed with GUARDAI_DETECTION_UNAVAILABLE (503, retriable) rather than falling back, so nothing below applies to that mode.

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

  • The runtime falls back to builtin regex detection only, so person, company, and location are not detected.
  • 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 whenever NER is configured (both or advanced) and 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 two deliberate exceptions. A policy that sets detection.required_for for an NER-backed type fails closed rather than under-detecting in silence. A sidecar response the runtime cannot trust also fails closed even though NER is optional here: a request the sidecar itself rejected (HTTP 4xx), and a response carrying an over-cap or inconsistent span, an over-large body, or a payload that would not parse. Those are response-integrity failures rather than outages, and degrading them to an empty result would let an NER-only-protected type pass raw.

The per-component behavior for every other dependency is in Failure Modes.

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 precision floor (detection.ner_min_confidence) drops low-confidence NER spans; raising it trades recall for precision. It applies to NER spans only, never to built-in regex detections, and is validated to [0.65, 1.0], so a policy may only tighten the built-in NER floor. Types a policy lists in required_for are exempt. It was renamed from min_confidence, which also filtered regex detections; the old key is now a hard load error.

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
Licence fileGUARDAI_LICENSE_FILE points at a file that cannot be read or is oversizedFatal
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.

The licence check refuses only a licence file this deployment cannot read, or one too large to be a licence, on the reasoning that a deployment handed a licence it cannot open must not silently run as if it had none. No licence at all is a supported deployment: it resolves to the developer edition and the server starts. A licence that does not cover this release logs a warning and starts. Nothing is gated by any of this: no capability is withheld and no request path changes based on the resolved licence, and the check is local, so the "no phone-home" property above holds for licensing too. The reported block is documented in Observability.

Licence verification is not operative in this release. No verifying key is compiled into the binary, so no licence file can be checked: a correctly signed licence and a forged one are indistinguishable to it. A licence the server can read reports malformed with a reason naming the missing key, is logged as an error, and runs as the developer edition without aborting the start, because it names a defect in the release that no operator can fix. That is why the fatal check above is scoped to a file that cannot be read or is oversized. Do not treat the licence file as an integrity control in this release: the mechanism is built and tested, and it is waiting on the signing key ceremony. Nothing is gated by it either way, so no security property in this document depends on it.

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, output-guard outage, 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). Kept verbatim in the durable record, fingerprinted in every copy that leaves the host (see below).
  • session_id, emitted only as a keyed HMAC-SHA-256 fingerprint, never the raw session id.
  • policy_applied: the policy name, alongside policy_content_hash (a SHA-256 over canonical resolved policy content) and policy_version. The durable record keeps the labels; logs and outbound copies replace the free-form name and version with separate keyed fingerprints while preserving the exact content hash.
  • 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. A policy-custom type name is free-form text the operator chose, so the durable record keeps it verbatim while every other copy reduces anything outside the built-in set to a placeholder.
  • 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 keyed 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), each omitted when empty. The durable record keeps free-form purpose and category labels; logs and outbound copies use separate keyed fingerprints. The closed legal-basis enum remains readable. Production constructors currently leave all three absent.

No audit event ever carries a detected value. Identifier handling, however, differs by destination, and the distinction matters when you decide where an audit record may be sent:

  • The durable record (what the file backend writes) keeps the tenant id, the policy name, and the entity labels verbatim. This is deliberate. It is the protected compliance record, and the per-tenant HMAC chain binds the tenant itself, so fingerprinting it there would break the integrity model. The writer requires a stable, trusted parent and a regular, runtime-owned, single-link leaf at exact mode 0600. It creates new leaves privately and can narrow only a strict permission superset. Treat the file as regulated data rather than as a log stream.
  • The log line on the guardai::audit target fingerprints the tenant, trace id, policy name, policy version, purpose, and data category, with distinct domains for each free-form field. It reduces custom entity labels to one placeholder. The exact policy-content hash and closed legal basis remain readable.
  • Every copy that leaves the host uses the same projection as the log line. Notification webhooks and audit export never restate the rule, and exports apply the tenant fingerprint to chain names and the tenant filter so a bundle remains correlatable without naming the tenant.

The protected file is accepted on any durable storage. The certified set (local ext4, XFS, Btrfs, and APFS on macOS) is accepted silently, while remote families (NFS, EFS, SMB, CIFS, Azure Files, 9p, Ceph) and unverified families (FUSE, overlayfs, and unknown filesystems) are accepted with a startup warning that names the fsync and locking caveat. Memory-backed filesystems (tmpfs, ramfs, hugetlbfs) are always rejected for the active log and exports. Set audit.require_certified_filesystem: true to restore the strict allowlist that refuses every remote, unverified, or unsafe-mount volume. Container deployments should mount a dedicated volume at the audit directory because container-layer contents do not outlive the container.

Tenant ids are operator-chosen labels. Choose them so they are not themselves personal data; the durable record stores them as given.

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.

The transparent proxy can use the same access-controlled file writer, but its request path admits audit records through a bounded, fail-open queue. Shutdown is clean only when accepted records are finalized and file custody is released within the deadline. Queue exhaustion or writer failure does not fail an upstream operation, so a successful forwarded request can be absent from the durable file. Server audit.strict and the server --compliance preset apply only to the runtime server and do not make proxy audit strict.

Offline governance runs through oguardai-server. Verification and export accept only owned, regular sources at mode 0400 or 0600 and keep one opened descriptor through header key selection, verification, and export collection. Export destinations are create-new 0600 files, never overwritten, with file and parent-directory durability before success. See Compliance Controls for the complete custody and failed-output procedure.


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. The payload uses the shared external projection: identifiers and every free-form audit label are keyed fingerprints, custom entity labels are reduced, and the durable record is never forwarded.
    • 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; the single-node Compose bootstrap keeps only CHOWN, SETUID, and SETGID to create the audit directory and step down to the runtime user
No privilege escalationallowPrivilegeEscalation: false, Compose no-new-privileges, systemd NoNewPrivileges=yes
System call filterThe systemd unit runs a SystemCallFilter allow-list (the base service set plus the sandbox calls the runtime needs, with the privileged, module, mount, swap, reboot, raw-io, clock, debug, and cpu-emulation classes denied); containers inherit the RuntimeDefault seccomp profile
Restricted address familiesThe systemd unit sets RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX, so the service can open only the socket families it uses
Resource ceilingsDocker Compose sets mem_limit and pids_limit, the systemd unit sets MemoryMax and TasksMax, and the Helm chart sets resources.limits, so memory and process count are bounded per deployment
Health checksThe image HEALTHCHECK curls the unauthenticated /livez probe. /v1/health is not usable as a container health check, because it requires the configured auth

OCR Subprocess Isolation

When an image endpoint is used, the runtime runs the Tesseract binary as a short-lived child process for each image: it writes the image to a temporary file, passes only that path plus the output path and language to Tesseract, reads back the recognized text, and removes the temporary files. Tesseract is a third-party executable, so its confinement is worth stating explicitly.

What the runtime confines itself:

  • The child is launched with only the image and output file paths and the language as arguments; no session state, encryption keys, or request context is passed as an argument.
  • The child's environment is cleared to a small allowlist before it starts, so it does not inherit the server's environment.
  • On Linux the child runs with no-new-privileges and, where the kernel supports it, a Landlock filesystem restriction, and it is given resource limits: a core-dump size of zero, a file-size cap that bounds what it can write, and optionally an address-space cap.
  • On Linux the child installs a seccomp syscall filter before it runs, where the kernel supports filter mode: it cannot create a process, open any socket, ptrace, share memory with, or send a process-directed signal to any process outside itself, or reach the namespace, io_uring, bpf, keyring, module, or mount surfaces. Two residuals remain: below Landlock 6.12 a thread-directed signal by a guessed thread id can still reach another process, since the filter keeps tgkill open for glibc's raise and abort; and on a kernel without filter mode the child falls back to the other layers and records that in its posture rather than failing the spawn.
  • Temporary input and output files are created per request and removed when the run finishes. If the runtime cannot unlink one, it empties the file in place so no transcript or input survives; the retention counter increments only when that emptying also fails.

What the deploy layer adds:

The runtime confinement above does not change the child's user, drop Linux capabilities, or place the process in a cgroup. Those are the container and pod's responsibility, and they are what bound CPU and memory and confine the server process itself:

ControlDeploy-layer settingEffect on the OCR child
Non-root userrunAsNonRoot: true with runAsUser, or the image USERThe child runs under an unprivileged uid, so a Tesseract compromise cannot act as root
Drop capabilitiescapabilities.drop: [ALL], allowPrivilegeEscalation: falseThe child holds no Linux capabilities and cannot regain them
CPU and memory boundsContainer resource limits (cgroup)A crafted image that pushes Tesseract into a runaway loop or large allocation is capped rather than exhausting the node
System call filterA seccomp profile (RuntimeDefault or a tighter custom profile)Stacks on top of the runtime's own child filter and also covers the server process; keep it. The profile must permit the seccomp and Landlock syscalls the child uses to confine itself (RuntimeDefault does)
Writable scratch onlyreadOnlyRootFilesystem: true plus a memory-backed temp mount with a size limit (Helm scratch.memoryBacked + scratch.sizeLimit)OCR scratch is held in memory for its whole lifetime (including a retained file), so it never touches the container writable layer, the node disk under the kubelet pod dir, or a persistent volume, though swap or hibernation can still page it (keep swap off on OCR nodes or accept the residual). The size limit caps scratch memory; it is not a stop for the run, so size it above the concurrent OCR footprint

Document extraction also runs in a child process, so apply these controls at the pod or container level to cover the server and every process it starts. See the deployment guide for the matching Kubernetes and Docker settings.

Network Security

  • Single port: The server exposes a single port (default: 3000).
  • TLS termination: TLS is off by default, and the server otherwise binds plaintext HTTP. Either terminate TLS at the load balancer or ingress controller, or terminate it in the server itself by setting server.tls.enabled with server.tls.cert_path and server.tls.key_path (env GUARDAI_TLS_CERT and GUARDAI_TLS_KEY). Plaintext HTTP must never carry raw input across an untrusted 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

  • Pinned versions: All dependency versions are pinned in Cargo.lock. No floating version ranges in production dependencies.
  • Vulnerability auditing in CI: cargo audit runs in the Rust job, pip-audit runs against python/detector-core and sdks/python, and pnpm audit --audit-level high runs against the TypeScript workspace. A finding fails the job.
  • Licence gate, TypeScript only: CI enforces a licence allowlist over the production dependency tree of the published @oguardai/* packages. There is no equivalent automated licence gate for the Rust or Python dependency trees; those are reviewed by hand.

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:7b1068662ab5aa11bb22cc33dd44ee55}}). The third segment is a per-token capability suffix (32 hex characters, 128 bits, in the default per-request scheme) that rehydrate matches exactly in constant time; 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 detected value in any log path, and the tenant, session, and trace identifiers are logged only as fingerprints
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 terminates either at the load balancer / ingress or in the server (server.tls.enabled with a certificate and key); it is off by default
  • The Python detector sidecar (if used) is on an internal network only
  • Container runs as non-root with read-only filesystem
  • Container CPU/memory limits, a seccomp profile, and dropped capabilities confine the Tesseract and document-extraction subprocesses at the pod or container level
  • 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, and pnpm audit pass with zero known vulnerabilities

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