""" aegis.config — Centralized configuration via environment variables. """ # Copyright (c) 2026 Juan Luna. All rights reserved. # SPDX-License-Identifier: LicenseRef-Aegis-Commercial-1.0 # Licensed under the Aegis Commercial License 1.0; see LICENSE and NOTICE. from __future__ import annotations import hashlib import hmac import json from functools import lru_cache from pathlib import Path from typing import Any, Literal from urllib.parse import urlparse from pydantic import AnyHttpUrl, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class AegisSettings(BaseSettings): model_config = SettingsConfigDict( env_prefix="AEGIS_", env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # ── Provider ───────────────────────────────────────────────────────── provider: str = Field( default="openai", description=( "LLM provider adapter to use. " "Valid values: openai, anthropic, gemini, openrouter. " "Controls request/response translation and auth header format. " "Set to 'openai' for any OpenAI-compatible endpoint (vLLM, Ollama, LM Studio)." ), ) provider_model: str = Field( default="", description=( "Override the model name sent to the upstream provider. " "When set, replaces the 'model' field in every request. " "Useful for routing all traffic through a single backend model " "regardless of what the client requests." ), ) # ── OpenRouter extras ───────────────────────────────────────────── openrouter_site_url: str = Field( default="", description="HTTP-Referer header for OpenRouter analytics (optional).", ) openrouter_site_name: str = Field( default="", description="X-Title header for OpenRouter analytics (optional).", ) # ── Anthropic extras ────────────────────────────────────────────── anthropic_api_version: str = Field( default="2023-06-01", description="anthropic-version header value. Change only when targeting a beta API version.", ) # ── Backend ────────────────────────────────────────────────────────── backend_url: AnyHttpUrl = Field( # Built as AnyHttpUrl rather than str so the declared default matches the # declared type. pydantic-settings validates defaults, so the runtime # value was already AnyHttpUrl; only the annotation disagreed. default=AnyHttpUrl("http://localhost:11434"), description=( "URL of the upstream LLM backend (OpenAI-compatible). " "Ignored for providers with a fixed base URL " "(anthropic: api.anthropic.com, gemini: generativelanguage.googleapis.com, " "openrouter: openrouter.ai/api/v1)." ), ) backend_api_key: str = Field( default="", description="API key forwarded to the upstream backend. If empty, will be fetched from Vault.", ) backend_timeout_seconds: float = Field( default=120.0, ge=1.0, le=600.0, description="Total timeout for upstream requests (seconds).", ) backend_connect_timeout_seconds: float = Field( default=10.0, ge=0.5, le=30.0, ) # ── Vault Integration ──────────────────────────────────────────────── vault_url: str = Field( default="", description="URL of the HashiCorp Vault server.", ) vault_role_id: str = Field( default="", description="AppRole RoleID for Vault authentication.", ) vault_secret_id: str = Field( default="", description="AppRole SecretID for Vault authentication.", ) vault_backend_secret_path: str = Field( default="secret/data/aegis/backend", description="Vault path for the LLM backend API key.", ) # ── Auth ───────────────────────────────────────────────────────────── api_keys: str = Field( default="", description=( "Comma-separated list of allowed API keys for proxy access. " "Leave empty to disable auth (NOT recommended for production)." ), ) audit_api_keys: str = Field( default="", description="Comma-separated API keys for the /audit/* read endpoints.", ) auth_disabled: bool = Field( default=False, description="Set True only for local development. Never in production.", ) api_key_scopes: str = Field( default="", description=( "Semicolon-separated HIPAA minimum-necessary scope restrictions per API key " "(a scoping control, not a compliance determination). " "Format: 'key1:scope1,scope2;key2:scope3'. " "Valid scopes: proxy:completions, audit:read, audit:export, audit:analytics. " "With legacy unmapped-principal compatibility enabled, keys not listed here receive " "all scopes; otherwise unmapped keys receive no scopes. " "Example: 'read-only-key:audit:read;export-key:audit:read,audit:export'. " "Set via AEGIS_API_KEY_SCOPES environment variable." ), ) signing_key: str = Field( default="", description=( "Dedicated HMAC-SHA256 signing key for the Merkle audit chain. " "MUST be separate from AEGIS_API_KEYS. " "Generate with: python -c 'import secrets; print(secrets.token_hex(32))' " "If empty and auth is not disabled, a warning is emitted at startup." ), ) auth_mode: Literal["api_key", "oidc", "mtls", "api_key_mtls", "oidc_mtls"] = Field( default="api_key", description="Single configured authentication mode; composite modes require both factors.", ) auth_identity_hmac_key: str = Field( default="", description="Secret used only to derive opaque stable credential identifiers.", ) api_key_principals_json: str = Field( default="", description=( "JSON object keyed by lowercase HMAC-SHA256 API-key digest using " "auth_identity_hmac_key. Each value contains " "tenant_id, roles, and scopes. Strict mode requires an entry for every configured key." ), ) allow_legacy_unmapped_api_key_principals: bool = Field( default=False, description=( "Compatibility-only opt-in that grants legacy API_KEY_SCOPES permissions to valid " "API keys without an explicit principal mapping in development mode. Unmapped keys " "otherwise authenticate with no roles or scopes, and strict mode always rejects them." ), ) development_tenant_id: str = Field(default="development") oidc_issuer: str = Field(default="") oidc_audience: str = Field(default="") oidc_jwks_url: str = Field(default="") oidc_algorithms: str = Field(default="RS256") oidc_tenant_claim: str = Field(default="tenant_id") oidc_roles_claim: str = Field(default="roles") oidc_leeway_seconds: int = Field(default=0, ge=0, le=300) oidc_http_timeout_seconds: float = Field(default=5.0, ge=0.1, le=30.0) mtls_trusted_proxy_cidrs: str = Field(default="") mtls_allowed_sha256_fingerprints: str = Field(default="") mtls_san_allowlist: str = Field(default="") mtls_tenant_san_prefix: str = Field(default="tenant:") mtls_default_roles: str = Field(default="proxy_user") # ── LDAP / Active Directory ─────────────────────────────────────────────── ldap_url: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "LDAP server URL for multi-factor identity assertion. " "Use 'ldaps://' for direct TLS (recommended) or 'ldap://' with " "AEGIS_LDAP_USE_START_TLS=true for StartTLS. " "Example: ldaps://dc.corp.example.com:636. " "Leave empty to disable LDAP authentication." ), ) ldap_base_dn: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "LDAP base Distinguished Name for user and group searches. " "Example: DC=corp,DC=example,DC=com" ), ) ldap_bind_dn: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Service-account DN for the initial directory search bind (least-privilege read). " "Example: CN=svc-aegis,OU=ServiceAccounts,DC=corp,DC=example,DC=com. " "Provide via AEGIS_LDAP_BIND_DN environment variable or Vault." ), ) ldap_bind_password: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Password for the LDAP service account. " "Provide via AEGIS_LDAP_BIND_PASSWORD or Vault; never hard-code." ), ) ldap_user_search_filter: str = Field( default="(|(sAMAccountName={username})(userPrincipalName={username}))", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "LDAP search filter template for user lookup. {username} is substituted " "with the RFC 4515-escaped login name. " "AD default covers sAMAccountName and UPN formats. " "POSIX LDAP alternative: (uid={username})" ), ) ldap_user_search_base: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "DN subtree for user searches. Defaults to ldap_base_dn when empty. " "Example: OU=Users,DC=corp,DC=example,DC=com" ), ) ldap_required_groups: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Comma-separated CN or DN values of LDAP groups the authenticated user " "must belong to (at least one). Empty string disables group check. " "Example: AegisUsers,AegisAdmins" ), ) ldap_ad_mode: bool = Field( default=True, description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Enable Active Directory extensions: nested group OID " "(1.2.840.113556.1.4.1941), memberOf enumeration, sAMAccountName lookup. " "Set False for plain RFC 4519 LDAP directories." ), ) ldap_use_start_tls: bool = Field( default=False, description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Upgrade plain ldap:// to TLS via StartTLS before bind." ), ) ldap_ca_certs_file: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Path to PEM CA bundle for LDAP TLS peer verification. " "Empty string uses the system default trust store." ), ) ldap_timeout_seconds: float = Field( default=10.0, ge=1.0, le=60.0, description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "Socket-level timeout (seconds) for LDAP connect and operations." ), ) def get_ldap_required_groups(self) -> frozenset[str]: if not self.ldap_required_groups: return frozenset() return frozenset(g.strip() for g in self.ldap_required_groups.split(",") if g.strip()) # ── mTLS / SSL Hardening ────────────────────────────────────────────── ssl_certfile: Path | None = Field( default=None, description="Path to the server SSL certificate file.", ) ssl_keyfile: Path | None = Field( default=None, description="Path to the server SSL private key file.", ) ssl_ca_certs: Path | None = Field( default=None, description="Path to the CA bundle used to verify client certificates (mTLS).", ) mtls_required: bool = Field( default=False, description="If True, the server will require and verify a client certificate.", ) cac_piv_required: bool = Field( default=False, description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-064).]** " "When True, mTLS client certificates must carry a recognized DoD CAC or GSA PIV " "certificate policy OID (DoDI 8520.02 / NIST SP 800-73-4 / GSA FPKI) and the " "Client Authentication EKU. EDIPI (CAC) or UUID (PIV-I) is extracted and logged. " "Requires ssl_ca_certs to be configured for proper chain validation." ), ) # ── Storage ─────────────────────────────────────────────────────────── wal_path: Path = Field( default=Path("./aegis.wal.jsonl"), description="Path to the Write-Ahead Log for the Merkle audit chain.", ) max_memory_nodes: int = Field( default=100_000, ge=1_000, description="In-memory Merkle chain cap (deque sliding window).", ) max_wal_bytes: int = Field( default=0, ge=0, description=( "Rotate the active WAL into an archived, immutable segment once it " "reaches this many bytes. 0 disables rotation (single unbounded WAL). " "Archived segments (named '.NNNNNN', mode 0o600) are " "retained for forensic completeness and replayed on startup; the " "audit chain is never truncated." ), ) max_forensic_bytes: int = Field( default=65_536, ge=0, le=65_536, description=( "Cap, in bytes, on each of the request and response previews an audit " "leaf embeds (hex-encoded, so up to 4x this many leaf bytes). Digests " "and sizes are always recorded; 0 keeps no preview. Lowering it is " "the precondition for zero-knowledge inclusion proofs (DOC-08 §6.3) " "and costs the preview evidence those bytes carry. Safe to lower on " "an existing chain: verification never rebuilds a leaf. The default " "is also the ceiling." ), ) s3_archive_enabled: bool = Field(default=False) s3_archive_bucket: str = Field(default="") s3_archive_prefix: str = Field(default="aegis/wal") s3_archive_region: str = Field(default="") s3_archive_retention_days: int = Field(default=365, ge=1, le=36_500) s3_archive_lock_mode: Literal["GOVERNANCE", "COMPLIANCE"] = Field(default="COMPLIANCE") s3_archive_spool_dir: Path = Field(default=Path("./aegis-archive-spool")) s3_archive_journal_path: Path = Field(default=Path("./aegis-archive.sqlite3")) s3_archive_max_spool_bytes: int = Field(default=1_073_741_824, ge=1) tsa_url: str = Field(default="") tsa_ca_file: Path | None = Field(default=None) tsa_evidence_dir: Path = Field(default=Path("./aegis-rfc3161")) # ── Runtime enforcement and bounded resources ───────────────────────── security_enforcement_mode: Literal["strict", "development"] = Field( default="strict", description=( "strict is the only production mode: required auth, durable signed evidence, " "distributed rate limiting, and privileged kernel controls. development is for " "isolated tests/local work and must never be used for a production claim." ), ) require_durable_evidence: bool = Field( default=True, description="Reject governed requests when signed durable evidence cannot be committed.", ) require_lsm: bool = Field( default=True, description="Require active AppArmor/SELinux confinement in strict runtime mode.", ) require_seccomp: bool = Field( default=True, description="Require an active seccomp filter in strict runtime mode.", ) max_request_body_bytes: int = Field( default=1_048_576, ge=1_024, le=16_777_216, description="Streaming HTTP body limit enforced before JSON parsing.", ) max_stream_response_bytes: int = Field( default=16_777_216, ge=1_024, le=268_435_456, description=( "Maximum cumulative canonical SSE response bytes before upstream is aborted. " "This is a total-output policy limit, not the retained-memory strategy." ), ) stream_queue_max_items: int = Field( default=64, ge=1, le=1_024, description="Maximum number of canonical SSE events retained per active stream.", ) stream_queue_max_bytes: int = Field( default=1_048_576, ge=1_024, le=16_777_216, description="Maximum canonical SSE bytes retained in one stream queue.", ) max_stream_event_bytes: int = Field( default=65_536, ge=256, le=1_048_576, description="Maximum accepted upstream or canonical SSE event size.", ) stream_deidentifier_window_chars: int = Field( default=128, ge=64, le=4_096, description="Finite logical-text holdback for cross-event PHI/PCI interception.", ) mmr_hash_scheme: Literal["auto", "v1-asciihex", "v2-binary-domain-separated"] = Field( default="auto", description=( "MMR hash construction for the audit ledger. 'auto' (the default) starts a NEW " "chain on 'v2-binary-domain-separated', which applies RFC 6962 domain tags and so " "prevents the leaf/interior-node type confusion v1 admits, and reopens an EXISTING " "chain under whichever scheme its WAL recorded. Pinning a scheme explicitly is " "fail-closed: a WAL written under the other one is refused with fault state " "'mmr_scheme_mismatch' rather than replayed to a different root. There is no " "in-place upgrade — the scheme decides every root a chain has recorded — so moving " "an existing v1 chain to v2 means starting a new chain." ), ) enable_cryptographic_shredding: bool = Field( default=False, description=( "Seal each committed leaf under a per-subject AES-256-GCM key so the record can " "later be rendered unreadable by destroying that key, leaving every root, peak and " "issued inclusion proof untouched. Off by default: it changes what the MMR commits " "to, so it cannot be turned on for a chain that already has records. It also makes " "the WAL carry ciphertext rather than only digests, so WAL growth tracks payload " "size. Destroying a key makes the sealed leaf unrecoverable to a holder of the " "ciphertext; it is not media sanitisation and does not remove the request and " "response digests the node still carries." ), ) shredder_vault_path: str = Field( default="", description=( "Where per-subject shredding keys live. Empty places the vault beside the WAL " "('.shredder.db'), which keeps the two under one custody boundary. The " "vault holds the keys that make the ledger readable: back it up with the WAL or " "lose every plaintext, and sanitise it on erasure or keep them past it." ), ) pqc_identity_path: str = Field( default="", description=( "Filesystem path to the ledger's persistent ML-DSA-65 signing identity. Empty (the " "default) disables the post-quantum signing tier entirely, so signing falls through " "to HMAC-SHA256. When set, the identity is created on first use and reused for every " "signature, which is what makes a signature attributable. The file holds the raw " "private key and is written 0600; give it the custody any signing secret needs." ), ) trusted_signing_public_keys: str = Field( default="", description=( "Comma-separated hex ML-DSA public keys whose signatures the ledger accepts in " "addition to its own identity's (AEGIS_PQC_IDENTITY_PATH): earlier identities after " "a rotation, and the identity of every other replica that writes the same chain in " "high availability. While the ledger holds its own identity or this list is " "non-empty, a pqc-ml-dsa record signed under any other key is invalid and fails " "verify_integrity, because a key read from the record itself is whatever whoever " "last wrote the WAL chose; with neither, such records read unverified. Take each " "key from the identity at provisioning, never from the chain." ), ) terminal_outbox_enabled: bool = Field( default=False, description=( "Spool every terminal commit a stream hands off at teardown to a durable, " "content-free outbox beside the WAL, and replay what is still pending at the next " "start, before traffic is served (REG-D32). Off, a crash or SIGKILL between " "teardown and the handed-off commit loses that terminal node, and a full handoff " "queue drops it. A recovered node is signed as stream-terminal-evidence-recovered " "with evidence_status recovered-terminal, carries no request or response preview, " "and is timestamped at replay. Requires AEGIS_SIGNING_KEY: every spool line is " "authenticated with a key derived from it, and a line that fails is quarantined, " "never replayed. If enabled without a signing key, or if the outbox cannot be " "opened, startup fails." ), ) terminal_outbox_path: Path | None = Field( default=None, description=( "Outbox spool file. Unset, it is .terminal-outbox.jsonl, beside the WAL " "and so covered by the same volume and single-writer lock." ), ) terminal_outbox_max_bytes: int = Field( default=16 * 1024 * 1024, ge=64 * 1024, description=( "Size at which the outbox stops accepting new records (each is about 0.5 KiB); " "past it a handoff is in-memory only and counted in " "aegis_terminal_outbox_errors_total. Compaction reclaims done records." ), ) max_concurrent_streams: int = Field( default=256, ge=0, description=( "Admission ceiling for concurrent governed SSE streams in this process. " "Per-stream memory is bounded (R_max), but until this existed the number of " "streams was not, so aggregate retained bytes scaled with however many " "connections an adversary opened. Over the ceiling, admission is refused with " "HTTP 429 before the upstream call rather than after buffers are allocated. " "0 disables the ceiling and restores the previous unbounded behaviour. " "This counts streams in ONE process: it is not a cluster-wide limit, and " "N replicas admit up to N x this value." ), ) streaming_engine: Literal["grammar_frontier", "deidentifier"] = Field( default="grammar_frontier", description=( "Streaming redactor for governed SSE. 'grammar_frontier' runs the Safe Harbor " "de-identifier and then the grammar-frontier rules, which add instruction-override " "and system-prompt-disclosure matching the de-identifier does not carry. " "'deidentifier' runs the Safe Harbor set alone, as releases before 4.3.0 did. " "The composite holds back the de-identifier window plus the 28-character frontier, " "so the per-stream retained-byte ceiling rises by 4x28 bytes." ), ) gossip_enabled: bool = Field( default=False, description=( "Run the cross-replica reconciliation daemon. Off by default: it opens a " "second listener, requires a cluster CA and per-replica certificates, and " "reconciles an accumulator that is separate from the audit ledger — so " "enabling it changes nothing about what the WAL records or what a receipt " "proves. It gives replicas one shared CRDT root; it is not consensus, it " "does not order the ledger across replicas, and it is not Byzantine " "fault tolerant." ), ) gossip_replica_id: int = Field( default=0, ge=0, le=4_294_967_295, description=( "This replica's identifier in the gossip mesh. Must be unique per replica " "and stable across restarts: it is the key in every vector clock, so two " "replicas sharing an id are indistinguishable to the causality tracking " "and a changed id abandons the entries recorded under the old one. Under " "the Helm chart, derive it from the StatefulSet ordinal." ), ) gossip_self_name: str = Field( default="", description=( "This replica's own name in the mesh, excluded from the peer list. The " "peer list is rendered from the replica count and cannot know which " "ordinal it is being rendered for, so a replica removes itself here. " "Under the Helm chart this is the pod name." ), ) gossip_peers: str = Field( default="", description=( "Comma-separated 'name=https://host:port' peers, excluding this replica. " "Static rather than discovered: there is no membership protocol, so " "adding or removing a replica is an operator action. The name must match " "the peer certificate." ), ) gossip_interval_seconds: float = Field( default=5.0, gt=0.0, le=3_600.0, description=( "Seconds between anti-entropy rounds. A converged cluster exchanges only " "a root digest per peer per round, so this is cheap to keep short; the " "cost of a longer interval is how stale a replica may be, not bandwidth." ), ) gossip_bind_host: str = Field( default="0.0.0.0", # noqa: S104 description=( "Interface the inbound gossip listener binds. Defaults to all interfaces " "because the mesh is reached by pod DNS, and the boundary that protects it " "is the mandatory client certificate rather than the bind address; narrow " "it further with a NetworkPolicy or by setting a specific address." ), ) gossip_bind_port: int = Field( default=9443, ge=1, le=65_535, description=( "Port for the inbound gossip listener. Separate from the gateway's port on " "purpose: customer traffic and replica traffic never share a listener, so " "one misconfiguration cannot expose both." ), ) # ── Multi-replica operation (aegis.core.ha) ──────────────────────────── ha_mode: str = Field( default="single", description=( "'single' (one writer, no coordination), 'active_passive' (replicas compete " "for one chain's writer lease; the holder serves, the others stand by), or " "'active_active' (every replica writes its own chain under its own lease and " "every committed node is appended to one shared, hash-linked global sequence)." ), ) ha_chain_id: str = Field( default="", description=( "Chain this replica writes. active_passive: the one chain all replicas " "share (required). active_active: this replica's own chain; defaults to the " "host name, which a StatefulSet makes unique and stable." ), ) ha_redis_url: str = Field( default="", description="Redis for the writer lease. Empty = redis_url.", ) ha_lease_ttl_seconds: float = Field( default=15.0, ge=3.0, le=300.0, description=( "Writer lease lifetime. The holder renews every third of it and stops " "admitting requests once less than a fifth remains unrenewed." ), ) ha_standby_poll_seconds: float = Field( default=2.0, ge=0.2, le=60.0, description="How often a standby replica retries the writer lease.", ) ha_sequencer_url: str = Field( default="", description=( "Global sequence store: postgresql://… (production) or sqlite:///absolute/path " "(single host, non-strict only). Required for active_active; optional for " "active_passive, where it adds storage-level fencing by lease epoch." ), ) ha_max_sequencing_lag_seconds: float = Field( default=30.0, ge=1.0, le=3600.0, description=( "Admission is refused (503) while the oldest durable, not-yet-sequenced " "node is older than this: the bound on how far the global sequence may " "trail the local chains." ), ) gossip_client_certificate: str = Field( default="", description="PEM certificate this replica presents to peers, in both directions.", ) gossip_client_private_key: str = Field( default="", description="PEM private key for the gossip certificate. Give it signing-key custody.", ) gossip_certificate_authority: str = Field( default="", description=( "PEM CA bundle that issues replica certificates. Required whenever gossip " "is enabled and there is no flag to skip verification: a peer supplies " "leaves that enter this replica's accumulator, so an unauthenticated mesh " "is an open input rather than a cluster." ), ) max_stream_duration_seconds: float = Field( default=60.0, ge=0.1, le=900.0, description="Total wall-clock deadline for consuming one upstream SSE response.", ) analysis_queue_size: int = Field( default=2_048, ge=1, le=100_000, description="Bounded asynchronous response-analysis queue capacity.", ) analysis_worker_count: int = Field( default=2, ge=1, le=64, description="Number of bounded response-analysis workers.", ) analysis_sample_rate: float = Field( default=1.0, ge=0.0, le=1.0, description="Fraction of successful responses submitted for asynchronous analysis.", ) analysis_timeout_seconds: float = Field( default=5.0, ge=0.1, le=60.0, description="Maximum wall time for one asynchronous response analysis job.", ) analysis_shutdown_timeout_seconds: float = Field( default=2.0, ge=0.1, le=30.0, description="Maximum shutdown wait for cancelled response-analysis workers.", ) # ── Telemetry ───────────────────────────────────────────────────────── force_logprobs: bool = Field( default=False, description=( "Inject logprobs=True and top_logprobs=N into every chat request " "for entropy analysis. Disabled by default because it inflates response " "size 5-10x and is unsupported by Anthropic/Gemini providers. " "Enable explicitly when using an OpenAI-compatible backend that supports it." ), ) top_logprobs: int = Field( default=20, ge=1, le=20, description="Number of top logprobs to request (max 20, OpenAI limit).", ) entropy_alert_threshold_bits: float = Field( default=1.0, ge=0.0, description="Shannon entropy drop (bits) below baseline that triggers ALERT.", ) kl_alert_threshold: float = Field( default=2.0, ge=0.0, description="KL divergence above which an ALERT is emitted.", ) js_alert_threshold: float = Field( default=0.5, ge=0.0, le=1.0, description="JS divergence above which an ALERT is emitted.", ) # ── Rate Limiting ───────────────────────────────────────────────────── rate_limit_requests_per_minute: int = Field( default=60, ge=1, le=10_000, description="Default request limit per minute per tenant/session.", ) rate_limit_threshold: int = Field( default=60, ge=1, le=10_000, description="Rate limit threshold (requests per minute).", ) rate_limit_window: int = Field( default=60, ge=1, le=3600, description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-066).]** " "Rate limit window in seconds. " "The active limiter uses its own fixed window (rate_limit_requests_per_minute, " "rate_limit_burst, rate_limit_token_capacity); this value is read by no code path." ), ) rate_limit_burst: int = Field( default=10, ge=1, le=100, description="Maximum burst capacity for the token bucket.", ) redis_url: str = Field( default="redis://localhost:6379", description="Redis connection URL for distributed rate limiting.", ) rate_limit_backend: str = Field( default="memory", description="Rate limiter backend: 'memory' (default, no Redis) or 'redis'.", ) require_distributed_limiter: bool = Field( default=False, description=( "Refuse startup, in any enforcement mode, unless rate_limit_backend is " "'redis'. Strict mode already requires Redis; this makes the example " "deployment variable an enforced control rather than an inert one (REG-D60)." ), ) rate_limit_token_capacity: int = Field(default=100_000, ge=1) rate_limit_tokens_per_minute: int = Field(default=100_000, ge=1) rate_limit_default_output_tokens: int = Field(default=4_096, ge=1) rate_limit_max_output_tokens: int = Field(default=16_384, ge=1) rate_limit_max_buckets: int = Field(default=10_000, ge=2, le=1_000_000) request_entropy_guard: bool = Field( default=False, description="Block requests failing Shannon-entropy heuristics (enable in hardened mode).", ) waf_strict_mode: bool = Field( default=True, description="Reject payloads that match known prompt-injection patterns.", ) wal_min_free_bytes: int = Field( default=0, ge=0, description=( "Refuse governed traffic at ingress when the WAL volume has less than this " "many bytes free. 0 (the default) disables the check. " "This is an EARLY-REFUSAL optimisation, not a missing safety net: a commit " "onto a full volume already fails closed -- the write or the fsync raises, " "`wal_persist_failed` latches, and governed endpoints answer 503. What the " "preflight adds is refusing BEFORE the upstream provider is called and billed, " "and an unambiguous operator signal. " "It defaults off because a false refusal on a nearly-full but working volume " "is a total outage, which is a worse failure than the one it prevents; an " "operator who knows their headroom sets it deliberately." ), ) rag_injection_scanning: bool = Field( default=True, description=( "Scan retrieved content — tool results, function results, and RAG context " "blocks — for embedded prompt injection before forwarding. This covers the " "indirect case the WAF structurally cannot: the user turn is clean and the " "payload arrives inside data the application fetched on the model's behalf. " "Set false where retrieved content legitimately contains role-like headers " "(system logs, chat transcripts, structured records), which the scanner's " "own documentation identifies as its false-positive mode. Disabling removes " "a detection; it does not weaken any evidence or durability guarantee." ), ) rag_injection_block_threshold: float = Field( default=0.5, gt=0.0, le=1.0, description=( "Minimum aggregate risk score at which retrieved content is refused. " "Raise toward 1.0 to admit more; 1.0 refuses only content that trips a " "signal weighted at full confidence. Validated here so a bad value fails " "at startup rather than on the first request that would have been scanned." ), ) waf_session_window: int = Field( default=10, ge=2, le=100, description=( "Sliding-window size (number of recent turns) examined by the multi-turn " "behavioral WAF. Older turns are evicted automatically." ), ) waf_session_cumulative_threshold: float = Field( default=2.0, ge=0.1, description=( "Sum of per-turn WAF scores within the session window that triggers " "a session-level block. Score per turn is in [0, 1]; default 2.0 " "requires at least two full-weight soft hits in the window." ), ) waf_session_crescendo_turns: int = Field( default=3, ge=2, le=20, description=( "Number of consecutive turns each producing a non-zero WAF score " "before a crescendo (gradual constraint erosion) block is triggered." ), ) # ── Server ──────────────────────────────────────────────────────────── debug_mode: bool = Field( default=False, description=( "Enable /docs and /redoc OpenAPI endpoints. " "NEVER enable in production — exposes full API schema. " "Automatically forces auth_disabled=False check at startup." ), ) host: str = Field(default="0.0.0.0") port: int = Field(default=8080, ge=1, le=65535) workers: int = Field(default=1, ge=1, le=64) log_level: str = Field(default="INFO") cors_origins: str = Field( default="", description="Comma-separated CORS allowed origins. Empty = no CORS headers.", ) dmz_allowed_source_ips: str = Field( default="", description=( "DMZ-mode: comma-separated list of IPv4/IPv6 addresses or CIDR networks " "that are allowed to reach the proxy. When non-empty, every request whose " "client IP is not in the allowlist is rejected with 403 Forbidden before " "any authentication is attempted. Supports exact addresses (e.g. 10.0.0.1) " "and prefix notation (e.g. 10.0.0.0/24, ::1/128). Empty string (default) " "disables DMZ mode — all source IPs are permitted. " "Configure via AEGIS_DMZ_ALLOWED_SOURCE_IPS." ), ) dmz_trust_proxy_headers: bool = Field( default=False, description=( "When True, DMZ-mode reads the real client IP from X-Forwarded-For or " "X-Real-IP headers (trusted reverse-proxy scenario). " "Never set this when the proxy is internet-facing — it allows IP spoofing. " "Only enable behind a trusted load balancer or nginx ingress." ), ) # ── Air-gap egress enforcement ──────────────────────────────────────── airgap_mode: bool = Field( default=False, description=( "When True, the proxy blocks all outbound HTTP connections except to hosts " "listed in AEGIS_AIRGAP_ALLOWED_HOSTS plus the configured upstream backend. " "Provides application-layer OT network zone enforcement " "(IEC 62443 Zone and Conduit model). " "Pair with kernel-level controls (nftables, network namespaces) for defense-in-depth. " "Configure via AEGIS_AIRGAP_MODE." ), ) airgap_allowed_hosts: str = Field( default="", description=( "Comma-separated list of allowed outbound hostnames or host:port pairs " "when AEGIS_AIRGAP_MODE=true. The upstream backend host is always implicitly " "allowed. Example: 'internal-llm.corp.example.com,10.0.0.5:8080'. " "An empty value (with airgap_mode=true) only permits the upstream backend. " "Configure via AEGIS_AIRGAP_ALLOWED_HOSTS." ), ) # ── HSM / PKCS#11 signing ───────────────────────────────────────────── pkcs11_library_path: str = Field( default="", description=( "Filesystem path to the PKCS#11 shared library. Empty string disables HSM signing. " "Examples: /usr/lib/softhsm/libsofthsm2.so (SoftHSM2), " "/opt/cloudhsm/lib/libcloudhsm_pkcs11.so (AWS CloudHSM). " "Requires python-pkcs11 to be installed (pip install python-pkcs11). " "When configured, audit nodes are signed by the HSM-resident key instead of the " "in-memory AEGIS_SIGNING_KEY; the private key NEVER enters application memory." ), ) pkcs11_slot_id: int = Field( default=0, ge=0, description="PKCS#11 slot index (0-based). Ignored when AEGIS_PKCS11_TOKEN_LABEL is set.", ) pkcs11_pin: str = Field( default="", description=( "PKCS#11 User PIN for C_Login. Provide via environment variable " "AEGIS_PKCS11_PIN or Vault; never hard-code in config files." ), ) pkcs11_key_label: str = Field( default="aegis-signing-key", description="CKA_LABEL of the private signing key stored in the PKCS#11 token.", ) pkcs11_token_label: str = Field( default="", description=( "When non-empty, resolve the slot by token label rather than pkcs11_slot_id. " "Useful when slot numbering is dynamic (e.g. AWS CloudHSM)." ), ) # ── Reliability: circuit breaker ────────────────────────────────────── circuit_breaker_failure_threshold: int = Field( default=5, ge=1, le=100, description=( "Consecutive upstream failures before the circuit opens. " "While OPEN all requests return 503 immediately (fail-fast). " "After circuit_breaker_recovery_timeout seconds one probe is allowed." ), ) circuit_breaker_recovery_timeout: float = Field( default=30.0, ge=1.0, le=600.0, description="Seconds the circuit stays OPEN before allowing a recovery probe.", ) circuit_breaker_success_threshold: int = Field( default=2, ge=1, le=20, description="Consecutive probe successes in HALF_OPEN required to re-close the circuit.", ) # ── Privacy / PII redaction ──────────────────────────────────────────── phi_deidentify: bool = Field( default=False, description=( "When True, apply best-effort regex PHI identifier redaction " "to request message content before forwarding to the upstream LLM, and to response " "content before returning to the client. Scrubs 18 HIPAA identifier categories via " "regex (names, DOB, SSN, MRN, phone, email, IP, URL, etc.). Does not require an " "NLP model. This is not a HIPAA Safe Harbor or Expert Determination conclusion." ), ) pci_scrub: bool = Field( default=False, description=( "When True, apply real-time PCI-DSS v4.0 cardholder-data detection and masking " "to request message content before forwarding to the upstream LLM, and to response " "content before returning to the client. Detects PANs (Luhn + IIN gate), " "CVV/CVC security codes (in context), and Track 1/2 magnetic-stripe data. " "PANs are masked to last-4 following the PCI-DSS §3.4 masking requirement — a technical control, not a PCI certification; CVV and track data are fully redacted. " "Enable for PCI-DSS-scoped deployments (payments, e-commerce, financial services)." ), ) pii_redact_tenant_id: bool = Field( default=False, description=( "When True, replace tenant_id (session/user identifier) in the WAL with " "a one-way SHA-256 prefix before committing to the audit chain. " "Supports data-minimization review where session IDs are " "considered personal data. Does not affect in-flight analysis — only " "the durable WAL record. Cannot be reversed without the original ID." ), ) # ── Alerting ────────────────────────────────────────────────────────── webhook_url: str = Field( default="", description=( "**[Not wired in 5.0.3 \u2014 no code path reads this field; setting it enforces nothing (UC-066).]** " "HTTP(S) URL to POST alert payloads to (Slack, Teams, custom SIEM). " "The alert sender reads siem_url / siem_* instead, so setting only this " "variable delivers nothing; see AUD-35 in ROADMAP.md." ), ) webhook_timeout_seconds: float = Field(default=5.0, ge=0.5, le=30.0) siem_url: str = Field(default="") siem_bearer_token: str = Field(default="") siem_format: Literal["cef", "rfc5424", "splunk", "datadog"] = Field(default="cef") siem_spool_path: Path = Field(default=Path("./aegis-siem.sqlite3")) siem_spool_max_rows: int = Field(default=100_000, ge=1) siem_spool_max_bytes: int = Field(default=268_435_456, ge=1) siem_max_payload_bytes: int = Field(default=16_384, ge=256, le=1_048_576) @field_validator("provider") @classmethod def _validate_provider(cls, v: str) -> str: allowed = {"openai", "anthropic", "gemini", "openrouter"} v_lower = v.strip().lower() if v_lower not in allowed: raise ValueError(f"AEGIS_PROVIDER must be one of {sorted(allowed)}, got {v!r}") return v_lower @field_validator("security_enforcement_mode") @classmethod def _validate_security_enforcement_mode(cls, v: str) -> str: value = v.strip().lower() if value not in {"strict", "development"}: raise ValueError("security_enforcement_mode must be 'strict' or 'development'") return value @field_validator("trusted_signing_public_keys") @classmethod def _validate_trusted_signing_public_keys(cls, v: str) -> str: keys = [key.strip() for key in v.split(",") if key.strip()] for key in keys: if len(key) % 2 or any(c not in "0123456789abcdefABCDEF" for c in key): raise ValueError( "AEGIS_TRUSTED_SIGNING_PUBLIC_KEYS must be comma-separated hex public keys" ) return ",".join(key.lower() for key in keys) @field_validator("rate_limit_backend") @classmethod def _validate_rate_limit_backend(cls, v: str) -> str: allowed = {"memory", "redis"} v_lower = v.lower() if v_lower not in allowed: raise ValueError(f"rate_limit_backend must be one of {allowed}") return v_lower @field_validator("log_level") @classmethod def _validate_log_level(cls, v: str) -> str: allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} v_upper = v.upper() if v_upper not in allowed: raise ValueError(f"log_level must be one of {allowed}") return v_upper @model_validator(mode="after") def _enforce_auth_posture(self) -> AegisSettings: """Validate backend URL and refuse authentication disablement in production. The ``debug_mode`` field documents that auth cannot be silently disabled in production. This makes that promise enforceable: ``auth_disabled`` is only honoured when ``debug_mode`` is also set. Without this check a stray ``AEGIS_AUTH_DISABLED=true`` in a production environment would open both the proxy and the ``/v1/audit/*`` endpoints with no credential check — a silent, config-only privilege escalation that no code path guards. """ parsed_backend = urlparse(str(self.backend_url)) if parsed_backend.scheme not in {"http", "https"} or not parsed_backend.hostname: raise ValueError("backend_url must be an absolute http/https URL with a hostname") if parsed_backend.username is not None or parsed_backend.password is not None: raise ValueError("backend_url must not contain URL userinfo") if self.auth_disabled and not self.debug_mode: raise ValueError( "auth_disabled=True requires debug_mode=True. Refusing to start " "with authentication disabled outside debug mode. Set " "AEGIS_DEBUG_MODE=true for local development, or remove " "AEGIS_AUTH_DISABLED and configure AEGIS_API_KEYS for production." ) if self.auth_disabled and "host" not in self.model_fields_set and self.host == "0.0.0.0": # REG-D75: an unauthenticated gateway holding the operator's backend # key used to listen on every interface by default. With auth # disabled and no explicit host, it now binds loopback only; an # explicit AEGIS_HOST (the evaluation compose file sets 0.0.0.0 inside # its container and publishes on host loopback) is respected. self.host = "127.0.0.1" if self.max_stream_event_bytes > self.stream_queue_max_bytes: raise ValueError("max_stream_event_bytes must not exceed stream_queue_max_bytes") if self.rate_limit_default_output_tokens > self.rate_limit_max_output_tokens: raise ValueError( "rate_limit_default_output_tokens must not exceed rate_limit_max_output_tokens" ) if self.auth_mode in {"oidc", "oidc_mtls"}: for name, value in ( ("oidc_issuer", self.oidc_issuer), ("oidc_audience", self.oidc_audience), ("oidc_jwks_url", self.oidc_jwks_url), ): if not value: raise ValueError(f"auth_mode={self.auth_mode!r} requires {name}") if self.auth_mode in {"mtls", "api_key_mtls", "oidc_mtls"}: if not self.get_mtls_fingerprints() or not self.get_mtls_san_allowlist(): raise ValueError("mTLS auth mode requires fingerprint and SAN allowlists") direct_tls = self.mtls_required and self.ssl_ca_certs is not None if not self.mtls_trusted_proxy_cidrs and not direct_tls: raise ValueError( "mTLS auth mode requires verified direct TLS or trusted proxy CIDRs" ) if self.s3_archive_enabled and not self.s3_archive_bucket: raise ValueError("s3_archive_enabled=True requires s3_archive_bucket") if self.s3_archive_enabled and self.max_wal_bytes == 0: raise ValueError("s3 archival requires max_wal_bytes > 0 to finalize segments") if self.tsa_url and self.tsa_ca_file is None: raise ValueError("tsa_url requires tsa_ca_file for explicit trust-policy verification") if self.siem_url: parsed_siem = urlparse(self.siem_url) if parsed_siem.scheme != "https" or not parsed_siem.hostname: raise ValueError("siem_url must be an absolute HTTPS URL") return self def validate_runtime_invariants(self) -> None: """Raise before binding sockets when strict runtime invariants are absent.""" if self.require_distributed_limiter and self.rate_limit_backend != "redis": raise ValueError("require_distributed_limiter=True requires rate_limit_backend='redis'") self.validate_ha() if self.security_enforcement_mode != "strict": return if self.debug_mode: raise ValueError("strict runtime cannot enable debug_mode") if self.auth_disabled: raise ValueError("strict runtime cannot disable authentication") if not self.require_durable_evidence: raise ValueError("strict runtime requires require_durable_evidence=True") if self.rate_limit_backend != "redis": raise ValueError("strict runtime requires rate_limit_backend='redis'") if self.auth_mode in {"api_key", "api_key_mtls"} and not self.get_api_keys(): raise ValueError("strict runtime requires at least one AEGIS_API_KEYS value") if not self.signing_key and not self.pkcs11_library_path: raise ValueError("strict runtime requires signing_key or pkcs11_library_path") if self.mtls_required and not self.ssl_ca_certs: raise ValueError("mtls_required=True requires ssl_ca_certs") if len(self.auth_identity_hmac_key.encode("utf-8")) < 32: raise ValueError("strict runtime requires auth_identity_hmac_key of at least 32 bytes") if self.auth_mode in {"api_key", "api_key_mtls"}: configured = self.get_api_key_principals() missing = sorted( self.api_key_principal_digest(key) for key in self.get_api_keys() if self.api_key_principal_digest(key) not in configured ) if missing: raise ValueError( "strict API-key mode requires an explicit principal mapping per key" ) def validate_ha(self) -> None: if self.ha_mode not in {"single", "active_passive", "active_active"}: raise ValueError("ha_mode must be 'single', 'active_passive' or 'active_active'") if self.ha_mode == "single": return if self.workers != 1: raise ValueError("ha_mode requires workers=1: each replica is one writer process") if not (self.ha_redis_url or self.redis_url): raise ValueError("ha_mode requires ha_redis_url or redis_url for the writer lease") if self.ha_mode == "active_passive" and not self.ha_chain_id: raise ValueError("ha_mode='active_passive' requires ha_chain_id (the shared chain)") if self.ha_mode == "active_active" and not self.ha_sequencer_url: raise ValueError("ha_mode='active_active' requires ha_sequencer_url") url = self.ha_sequencer_url if url and not url.startswith(("postgresql://", "postgres://", "sqlite:///")): raise ValueError("ha_sequencer_url must be postgresql://… or sqlite:///absolute/path") if url.startswith("sqlite") and self.security_enforcement_mode == "strict": # SQLite's file locking is not safe on the network filesystems replicas # would share, and its I/O path is outside the strict seccomp allowlist. raise ValueError("strict runtime requires a postgresql:// ha_sequencer_url") def get_api_keys(self) -> frozenset[str]: if not self.api_keys: return frozenset() return frozenset(k.strip() for k in self.api_keys.split(",") if k.strip()) def get_audit_api_keys(self) -> frozenset[str]: if not self.audit_api_keys: return self.get_api_keys() return frozenset(k.strip() for k in self.audit_api_keys.split(",") if k.strip()) def api_key_principal_digest(self, key: str) -> str: """Return the domain-separated keyed lookup digest for an API key.""" return hmac.new( self.auth_identity_hmac_key.encode("utf-8"), b"aegis-api-key-principal-v1\x00" + key.encode("utf-8"), hashlib.sha256, ).hexdigest() def get_api_key_principals(self) -> dict[str, dict[str, object]]: if not self.api_key_principals_json: return {} try: value = json.loads(self.api_key_principals_json) except json.JSONDecodeError as exc: raise ValueError("api_key_principals_json must be valid JSON") from exc if not isinstance(value, dict): raise ValueError("api_key_principals_json must be a JSON object") result: dict[str, dict[str, object]] = {} for digest, record in value.items(): if ( not isinstance(digest, str) or len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest) or not isinstance(record, dict) ): raise ValueError( "API-key principal mappings require HMAC-SHA256 keys and object values" ) result[digest] = dict(record) return result def get_oidc_algorithms(self) -> tuple[str, ...]: values = tuple(value.strip() for value in self.oidc_algorithms.split(",") if value.strip()) if not values: raise ValueError("oidc_algorithms must contain at least one algorithm") return values def get_mtls_fingerprints(self) -> frozenset[str]: return frozenset( value.strip() for value in self.mtls_allowed_sha256_fingerprints.split(",") if value.strip() ) def get_mtls_san_allowlist(self) -> frozenset[str]: return frozenset( value.strip() for value in self.mtls_san_allowlist.split(",") if value.strip() ) def get_mtls_proxy_cidrs(self) -> tuple[str, ...]: return tuple( value.strip() for value in self.mtls_trusted_proxy_cidrs.split(",") if value.strip() ) def get_cors_origins(self) -> list[str]: if not self.cors_origins: return [] return [o.strip() for o in self.cors_origins.split(",") if o.strip()] def get_dmz_networks(self) -> list[Any]: """Parse AEGIS_DMZ_ALLOWED_SOURCE_IPS into ipaddress network objects.""" import ipaddress if not self.dmz_allowed_source_ips: return [] networks = [] for entry in self.dmz_allowed_source_ips.split(","): entry = entry.strip() if not entry: continue try: # Accept bare IPs (treat as /32 or /128) or CIDR notation networks.append(ipaddress.ip_network(entry, strict=False)) except ValueError as exc: raise ValueError( f"AEGIS_DMZ_ALLOWED_SOURCE_IPS: invalid address or network {entry!r}: {exc}" ) from exc return networks def get_egress_guard(self) -> Any: """Build an EgressGuard from airgap_mode + airgap_allowed_hosts.""" from aegis.proxy.egress_guard import build_egress_guard return build_egress_guard( airgap_mode=self.airgap_mode, allowed_hosts_csv=self.airgap_allowed_hosts, upstream_url=self.backend_url_str, ) @property def backend_url_str(self) -> str: return str(self.backend_url).rstrip("/") @lru_cache(maxsize=1) def get_settings() -> AegisSettings: return AegisSettings()