4 Governance Domains

Admina organises its governance capabilities into 4 domains. Each domain groups related controls under a single engine with clear boundaries, so you always know what runs, where, and at what latency cost.

D1

Data Sovereignty

Rust + spaCy NER ยท Presidio optional

Ensures that sensitive data never leaves the boundaries you define. PII is automatically detected and redacted, data residency rules are enforced in the SDK, and every data type is classified for compliance tracking.

PII Redaction

Detects and redacts email addresses, phone numbers, credit cards, SSNs, IBANs, IP addresses, and person names (via spaCy NER + regex). The Rust engine processes each scan in 0.62µs median.

Redaction reads text values and keeps the structure around them (since v0.13.0). On /mcp tool parameters and results, and in GovernedAgent, the values of an object go to the PII engine and its keys are kept as they are โ€” JSON keys, ids, roles and tool names are no longer masked; GovernedAgent(redact_keys=True) redacts the keys too. On the OpenAI-compatible gateway only the text of each chat message is redacted: its content (a string, or the text of each part), reasoning and refusal text, and tool-call arguments; roles, names, tool-call ids, image and audio parts are forwarded as received.

A mask already in the text โ€” a placeholder such as [IBAN], [LOCATION], a category name in brackets such as [IP_ADDRESS], or [OMISSIS] โ€” is never masked again, so text that went through Admina once comes out unchanged the second time. Other text in square brackets is scanned like any other. E-mail matching and building the masked text both take time linear in the length of the input, however many spans are masked.

Data Residency Enforcement

Restricts data to allowed geographic or logical zones โ€” EU, local, or custom-defined regions. This is an SDK feature: zones are checked by GovernedData(residency_zone=โ€ฆ, allowed_zones=โ€ฆ), which raises PermissionError and emits a BLOCK audit event on every ingest or query that would leave the allowed zones. The governance proxy does not run residency checks on proxied traffic.

Data Classification

Categorizes data types automatically so downstream compliance checks (Domain 4) can match each payload against the correct regulatory requirements. Since v0.13.0 DataClassifier classifies the special categories of personal data of GDPR art. 9 (health, genetic, biometric, political opinions, โ€ฆ) and art. 10 (criminal convictions and offences) as restricted, together with any category names passed as special_categories โ€” such as those a PII engine declares. It classifies category names it is handed; the built-in PII engines do not detect health or judicial content.

Patterns detected

EMAILuser@example.com → [EMAIL]
PHONE+39 055 123456, 333 1234567 → [PHONE]
CREDIT_CARD4111 1111 1111 1111 → [CREDIT_CARD]
SSN123-45-6789 → [SSN]
IBANIT60X0542811101000000123456 → [IBAN]
IP_ADDRESS192.168.1.1 → [IP_ADDR]
IT_CODICE_FISCALERSSMRA85T10A562S → [CF]
ES_DNI_NIE12345678Z → [DNI]
PERSONJohn Smith → [PERSON] (NER)
ORGAcme Corp → [ORG] (NER)
GPE / LOCPisa, Italy → [LOCATION] (NER)

NER (Named Entity Recognition) in the default engine uses spaCy en_core_web_sm. Regex-based patterns work for all languages, and the default engine already covers the Italian codice fiscale and Spanish DNI/NIE by regex. Since v0.11.0 the optional Presidio engine adds Italian NER via it_core_news_sm and maps its own IT/ES identifier recognisers onto the same categories โ€” see Selectable PII engine below.

IBAN and phone coverage (v0.13.0). The spacy-regex engine's IBAN category follows the IBAN registry: a string is masked only when it has the length of its country (Italy: 27 characters), compact or in single-space groups, and a valid mod-97 checksum. An IBAN-shaped string with a wrong checksum, the wrong length or an unknown country code is no longer masked as an IBAN. PHONE also covers Italian mobile and landline numbers, with +39, 0039 or no prefix. IBANs are matched before card numbers, and card numbers before phone numbers. Both changes are in the Python regex path; the Rust PII scanner (used only under ADMINA_ENGINE=rust) has its own patterns and its own masks ([EMAIL_REDACTED], [IBAN_REDACTED], โ€ฆ), and no NER or EU national IDs. Since v0.14.1 it reports a card number only when it passes the Luhn check and matches the phone formats of the Python redactor (North American numbers, Italian mobile and landline numbers), so order numbers, ISBNs and build numbers are no longer masked.

Mask style (v0.13.0)

ADMINA_PII_MASK_STYLE (or the top-level pii_mask_style key in admina.yaml; the variable wins) chooses what replaces a detected span, in every engine, in requests, responses and streamed responses:

typedDefault. Each span is replaced by the mask of its type โ€” [EMAIL], [PERSON], [IBAN]
omissisEach span is replaced by [OMISSIS], so no type is left in the text. The sentence_categories an engine declares (health or judicial data, say) have their whole sentence replaced, and StreamRedactor then releases such a stream a sentence at a time, holding at most max_hold_chars (default 4096)

Any other value raises ValueError. The built-in engines declare no sentence categories, so with them omissis masks span by span; whole-sentence masking comes from an engine of another package that declares them.

Graceful fallback (v0.9.2+). spaCy is imported lazily, so the proxy boots without the [nlp] extra. In that case PII redaction runs in regex-only mode (email, phone, credit card, SSN, IBAN, IP address, EU national IDs). Install admina[nlp] and run python -m spacy download en_core_web_sm to enable the NER entities above (PERSON, ORG, GPE/LOC).

PERSON only when it reads as a name (v0.14.1). The PII redactor, the spacy-regex engine and the Presidio engine mask a PERSON entity of the NER model only when it has no digit, no character of an e-mail address or a path (@ / \ _ # : =) and no lowercase stop word of English, Italian, German, French, Spanish or Portuguese (words that are also first names โ€” "will", "may", "sara", "mia", "ali" โ€” are kept). The English model labelled phrases such as "il codice articolo" or "das Wetter" as PERSON; names written in lowercase ("mario rossi") are still masked.

Selectable PII engine (v0.11.0)

Since v0.11.0 the PII engine is selectable. The default is unchanged โ€” spacy-regex (spaCy NER + regex, optionally Rust-accelerated). The alternative is Microsoft Presidio, selected with ADMINA_PII_ENGINE=presidio or the top-level pii_engine: presidio key in admina.yaml โ€” a sibling of forensic_store: and auth_provider:, not nested under domains.

Presidio is used analyzer-only: it performs detection, while Admina keeps its own masking. Both engines read their mask tokens from the same table, so the output mask format is identical โ€” [EMAIL], [PERSON], [IBAN] and so on โ€” which gives drop-in mask compatibility between them.

pip install "admina[presidio]"
python -m spacy download en_core_web_sm it_core_news_sm

The extra pulls presidio-analyzer only โ€” Admina does its own masking, so presidio-anonymizer is not installed.

By default the languages are EN and IT, each active only if its spaCy model is actually installed. Install only en_core_web_sm and you silently get an EN-only Presidio engine, with the Italian fiscal-code recogniser absent. Since v0.13.0 ADMINA_PRESIDIO_NLP_MODELS (or presidio.nlp_models in admina.yaml) sets the spaCy pipeline of each language explicitly โ€” it:blank,en:en_core_web_sm โ€” where blank is a tokenizer with no model and no NER: the pattern recognisers (e-mail, IBAN, fiscal codes, phone numbers) still run, without the model download. A configured model that is not installed, or a malformed setting, stops the engine; models are never downloaded.

The Presidio โ†’ Admina category mapping covers EMAIL, PHONE, CREDIT_CARD, IBAN, IP_ADDRESS, SSN, PERSON, LOCATION/GPE, ORG, Italian codice fiscale and Spanish DNI/NIE. Categories the default engine has but Presidio cannot emit: date of birth, LOC, and the German Personalausweis (the latter off by default in both engines). Overlapping detections are masked as one span, their union (v0.13.0). Since v0.12.2 the analyzer is asked only for the entity types that mapping uses: it used to run every Presidio recogniser, and the URL recogniser alone took about 80 seconds on 64,000 characters of text with many dots. E-mail domains are checked against the public-suffix list bundled with tldextract, with no download.

PII engines from other packages (v0.13.0)

A name that is not a built-in engine is looked up among the entry points of the admina.pii_engines group: an installed package can register a BasePIIEngine subclass (or a factory returning one) and make it selectable with ADMINA_PII_ENGINE=<name>. A config parameter receives the engine's plugin_config block of admina.yaml. The engine runs through admina.engines.PIIEngineBridge, which reports entities as type, offsets and length โ€” never the text โ€” and masks only outside existing placeholders. An unknown name raises ValueError listing the built-in and registered engines, and the proxy does not start.

Choose deliberately. Engine selection is fail-fast: an unavailable or misspelled engine raises instead of falling back to the default, and in the proxy that aborts startup โ€” as, since v0.13.0, does ADMINA_ENGINE=rust without admina-core installed (EngineSelectionError; it used to warn and fall back to Python). Both engines read the built-in category defaults under the proxy โ€” neither path is handed a config object, so a per-deployment narrowing under domains.data_sovereignty.pii.categories in admina.yaml is not applied to proxied traffic; it only takes effect when you construct PIIRedactor(config=โ€ฆ) yourself in the SDK. With Presidio ADMINA_SPACY_MODEL is also ignored (its models are fixed). Selecting a named PII engine also bypasses Rust acceleration, which only ever applies to spacy-regex. For the measured detection comparison between the two engines see the Rust engine guide.
D2

AI Infrastructure

Python (opt-in)

An opt-in domain that provides a fully governed AI stack โ€” LLM serving, RAG pipelines, and a Web UI โ€” all enabled via admina.yaml configuration.

LLM Engine

Abstracts Ollama and vLLM backends with automatic GPU detection (NVIDIA and AMD). Supports hot model switching without downtime โ€” swap models while live traffic continues to be served.

RAG Pipeline

ChromaDB vector store with recursive character and semantic chunking. Ingests multiple formats: PDF, DOCX, HTML, CSV, XML.

Web UI

Open WebUI container with built-in OIDC and LDAP authentication. Provides a chat interface for end-users while all traffic flows through the Admina governance proxy.

Note: This domain is opt-in. Enable it in your admina.yaml under the domains.ai_infra section.
D3

Agent Security

Rust RegexSet

Protects the full agent lifecycle โ€” from prompt injection attacks to runaway loops โ€” with microsecond-latency checks that apply to every agent-to-agent call transiting the proxy. Since v0.12.0 it also controls where a tool call may go: a destination allowlist and a cross-agent coordination detector, both recording without refusing until you switch them to enforce.

Anti-Injection Firewall

The firewall is a heuristic signal, not a guarantee. The Python engine compiles 44 builtin regex patterns across 13 categories (33 across 9 before v0.13.0), matched against the raw text and against an evasion-normalised copy (homoglyph, leetspeak, char-by-char, base64, ROT13), plus heuristic scoring โ€” 7.79µs median, measured on the pre-0.13 33-pattern set (the 44-pattern set is slower; no updated figure is published). With admina-core installed the optional Rust engine runs its own narrower 15-pattern RegexSet in a single pass at 2.08µs median, with no evasion normalisation and none of the Italian baseline below; see the Rust engine guide for the detection trade-off. Both figures are the cost of one engine call on a short text, not the latency the proxy adds, which grows with the length of the text scanned. Since v0.13.0 every builtin pattern matches in time linear in the input length, so a long input cannot stall the matcher; that rewrite by itself does not change what the patterns match (the narrowing of the Italian patterns described below is a separate change).

The 13 builtin categories โ€” the labels the builtin patterns report: in detections_by_type, as the category of the admina_firewall_detections_total series on /metrics, in X-Admina-Categories, and as disabled_categories entries (see MODEL_CARD.md ยง3 for definitions and known limitations):

instruction_override role_hijack prompt_extraction jailbreak delimiter_injection data_exfiltration tool_abuse obfuscation multilang_evasion it_instruction_override it_role_hijack it_prompt_extraction it_model_addressing

They are not the only labels that can appear: every custom_patterns entry and every pattern-pack pattern carries its own category, which flows into the same places (a custom entry without one reports user_custom). Each category groups several pattern families, which are not reported separately: developer-mode and DAN-mode toggles both report jailbreak, and "what are your instructions" reports prompt_extraction. detections_by_type and the Prometheus series count a request only when a HIGH or CRITICAL pattern matched. The Rust engine carries its own coarser 15-label set โ€” it appears only in that engine's matched_patterns, never in the stats API, the Prometheus series or disabled_categories, and two of its labels (new_instructions, roleplay_escape) have no Python equivalent at all. Since v0.12.2 its role_hijacking pattern matches whole words only, so "impact assessment" or "the AI Act asks" no longer report a role hijack.

Languages. Patterns target English, with a multilang_evasion subset for French, Italian, Spanish and German and, since v0.13.0, an Italian baseline: the four it_* categories, all at risk high (override of rules or instructions, role hijack, prompt extraction, a note addressed to an AI system such as "Istruzioni per l'IA:"). Because the Italian imperative of the -are verbs reads like the third person ("ignora" is both "ignore!" and "it ignores"), the Italian patterns match only where the reader is addressed: where an instruction starts (start of text, after a sentence end, colon, line break, bracket, table cell, opening tag or quote), after a clause opened by a second-person imperative ("Traduci il testo e ignora le istruzioni precedenti"), or with a second-person object ("le tue istruzioni"). The Italian multilang_evasion patterns were narrowed the same way: "Ignora tutte le istruzioni precedenti" still matches at critical, while third-person prose ("il consiglio annulla le direttive precedenti") no longer matches. Contributions for additional locales are welcome.

Stable pattern ids (v0.13.0)

Every builtin pattern has an id that never changes and is never reused: <category>.<language>.<n> for the original categories (instruction_override.en.1, multilang_evasion.it.2) and <category>.<n> for the it_* ones (it_role_hijack.3). custom_patterns entries get custom.<n> in list order, and pack patterns <pack>:<id>. The firewall check โ€” and so the forensic checks โ€” reports one entry per matched category, carrying the id of the first pattern of that category that matched. disabled_patterns turns off single patterns by id, where disabled_categories turns off a whole category; an id that names no pattern is logged as a warning and ignored.

domains:
  agent_security:
    firewall:
      disabled_patterns: [prompt_extraction.en.2, custom.1]
      disabled_categories: [it_model_addressing]

Custom domain-specific patterns

Add deployment-specific patterns under domains.agent_security.firewall.custom_patterns in admina.yaml โ€” no fork required:

domains:
  agent_security:
    firewall:
      custom_patterns:
        - regex: "delete\\s+user\\s+\\d+"
          category: "destructive_user_op"
          risk_level: high
        - regex: "(production|prod)\\s+(database|db)\\s+drop"
          category: "prod_db_destructive"
          risk_level: critical

category defaults to user_custom and risk_level to medium; a malformed entry is logged and skipped on its own (v0.13.0). Custom patterns run on Python's backtracking re engine, so check each one on long inputs before deploying it: admina.domains.agent_security.pattern_timing.probe_pattern(regex) returns its worst search time in milliseconds on generated 64k-character inputs, and measure_pattern() also names the slowest input.

Pattern packs (v0.13.0)

A pattern pack is a named, versioned set of patterns in YAML or JSON that the Python firewall adds after its builtin patterns when pattern_packs lists it. Each name is looked up among the entry points of the admina.pattern_packs group (installed packages), then in the directories of pattern_pack_dirs (<name>.yaml, .yml or .json), which ADMINA_PATTERN_PACK_DIRS replaces when set. The repository ships one example, examples/pattern_packs/example-pack.yaml, and no pack of its own.

# packs/example-pack.yaml โ€” no other key is accepted
name: example-pack
version: "1.0.0"
description: Example patterns.   # optional
patterns:
  - id: internal_notes            # reported as example-pack:internal_notes
    regex: "\\binternal\\s++notes\\b"
    category: example_disclosure
    risk_level: high

# admina.yaml
domains:
  agent_security:
    firewall:
      pattern_packs: [example-pack]
      pattern_pack_dirs: [./packs]
      strict_pack_timing: false   # true: a slow pattern stops startup

Packs are strict: a pack not found (the error lists those available), provided by two sources, listed twice or invalid (the error names the file and the key path), and a missing pack directory raise PatternPackError, and the proxy does not start. Each pack pattern is timed on 64k-character inputs when the firewall is built: one over 50 ms is logged as a warning with its id, or stops startup with strict_pack_timing: true.

Deep path and heuristic threshold

When no HIGH or CRITICAL pattern matched, the Python firewall scores five heuristic signals (imperative words, special characters, context switches, length, escape sequences) and flags a text whose score reaches heuristic_threshold (default 0.5; any finite number above 0, else startup fails). Since v0.13.0:

  • allowed_tags lists tag names (any case) that do not count as context switches โ€” such as the tag your application wraps around retrieved documents. Separators, ###, code fences and other tags still count.
  • HTML entities (&euro;, &#8364;) no longer count as encoding markers; only \uXXXX escape sequences do (percent-encoding never counted).
  • The length signal starts above 100,000 characters (it was 2,000).
  • INJECTION_DEEP_PATH_ENABLED=false turns the deep path off on either engine: a text is then flagged by the patterns only. heuristic_threshold and allowed_tags apply to the Python firewall; the Rust engine scores with signals and a threshold of its own.
domains:
  agent_security:
    firewall:
      heuristic_threshold: 0.5
      allowed_tags: [source]
Upgrade note โ€” heuristic_threshold is now applied. Up to 0.12 the Python firewall ignored this key and always used 0.5, while the files that admina init generated, and copies of admina.yaml.example, set 0.7. From v0.13.0 the value in the file is read, so such a file makes the deep path flag fewer texts than before. Set heuristic_threshold: 0.5 to keep the previous behaviour.

Which engine runs the firewall

custom_patterns, disabled_categories, disabled_patterns and pattern_packs are rules only the Python firewall applies. Under ADMINA_ENGINE=auto (the default) setting any of them makes the firewall Python, with a warning naming the keys, so operator rules are actually enforced. Under ADMINA_ENGINE=rust it is an EngineSelectionError since v0.13.0 and the proxy does not start (it used to fall back to Python): remove the keys or switch to auto or python. pattern_pack_dirs, strict_pack_timing, heuristic_threshold and allowed_tags do not select an engine. GET /health reports the engine the firewall actually runs on under engine.firewall โ€” see the API reference.

Governance reaction mode

Switch how the firewall reacts to flagged traffic, useful for the first 1โ€“2 weeks of a new deployment:

enforceDefault โ€” block every flagged request, at whatever severity
observeNever block; log "would have blocked" for review and FP tuning
dry-runLike observe + tag the response so downstream tools know the request was analysed

Scan depth (v0.13.0)

The firewall and PII redaction walk a request's strings 32 levels deep (admina.domains.governance.SCAN_DEPTH; the body is level 0). Up to 0.12, /mcp and /api/v1/validate stopped at 6 and let deeper text through unscanned and unredacted โ€” an injection nested in five objects inside a tool call's arguments was allowed. Now a request holding text deeper than the limit is blocked in enforce mode (a would-be block in observe and dry-run): on /mcp with the firewall or PII redaction on, and on the gateway with the firewall on. /api/v1/validate (which refuses a non-string content with 400) and GovernedModel (a string prompt) cannot carry text that deep, and GovernedAgent, which reimplements the pipeline, has no such block. A blocked request's record carries checks.scan_depth = {"action": "BLOCK", "reason": "depth_limit_exceeded"}. The egress stage keeps a shallower limit of its own โ€” see below.

On the OpenAI-compatible gateway the firewall scans every string of the chat completion body, keys included: the messages, the tools definitions, response_format and any other field, with tool-call arguments scanned both as the JSON they hold and as received. ADMINA_GATEWAY_SCAN_ROLES and the opt-in X-Admina-Scan-Policy header narrow only the messages scanned โ€” see the API reference and Configuration.

Loop Breaker

TF-IDF + cosine similarity on a sliding window of recent requests (configurable threshold 0.85). Latency: 2.38µs. Automatically circuit-breaks sessions before runaway costs or deadlocks occur.

Egress control (v0.12.0)

A pipeline stage that judges a tool call by where it goes, never by its HTTP method โ€” a method is an assertion made by the resource being called, not a security boundary. It extracts destinations from the call's arguments โ€” on /mcp, the JSON-RPC params of every method, which for tools/call are the tool name and arguments โ€” and checks them against an operator allowlist under domains.agent_security.egress in admina.yaml. The stage sits after PII redaction and before the pluggable guards, so the canonical order is now loop โ†’ firewall โ†’ PII โ†’ egress โ†’ guards, and a refused destination is never handed to third-party guard code.

domains:
  agent_security:
    egress:
      enabled: true   # default; false skips the stage entirely
      # surfaces the stage runs on (since v0.13.0); unset = all, [] = none
      surfaces: [mcp, gateway, integration, sdk]
      # exact host, "*.suffix" wildcard, or CIDR โ€” no path rules
      allow:
        - api.openai.com
        - "*.corp.internal"
        - 10.0.0.0/8
      # exempt from the payload-bearing classification
      read_only_tools: [docs_search]

How the stage reacts is set by ADMINA_EGRESS_MODE, separate from the firewall's reaction mode:

observeDefault. Destinations are recorded in the decision's checks["egress"] and nothing is refused. A would-be refusal is still visible there: reason says why, and blocked lists the off-allowlist or quarantined hosts (it stays empty for an undeterminable destination)
enforceDefault-deny: a destination not on allow is refused, and so is a call whose destination cannot be determined

The default is observe so that upgrading a deployment never silently turns on default-deny, and an unrecognised value falls back to observe with a warning. The governance mode acts as a ceiling: under observe or dry-run the egress stage never refuses anything, whatever ADMINA_EGRESS_MODE says, so refusal needs both set to enforce. On the proxy surfaces that ceiling is ADMINA_GOVERNANCE_MODE; for GovernedModel it is the constructor's mode= argument (default enforce) โ€” the SDK does not read ADMINA_GOVERNANCE_MODE, but it does read ADMINA_EGRESS_MODE. ADMINA_EGRESS_MODE is read straight from the process environment: a line in .env only takes effect where something exports it, as Docker Compose's env_file does.

With enforce and an empty allow list (the default), every call that names a destination is refused. Since v0.13.0 a value of the wrong type anywhere in admina.yaml (say window_seconds: abc) is a ConfigSchemaError naming the key, and the proxy does not start โ€” it used to yield an empty allowlist. So does a YAML syntax error, and any problem with a file named by ADMINA_CONFIG (ConfigFileError). In the SDK, which has no startup check, an admina.yaml found by the default search that cannot be parsed still yields an empty allowlist โ€” deliberately fail-closed, with a warning naming the error. If every destination is suddenly refused, check the logs for that warning first.

surfaces (since v0.13.0) lists where the stage runs, among gateway, mcp, integration (/api/v1/validate) and sdk (GovernedModel.ask() and .stream()): unset means every surface, an empty list none. Names are case-insensitive; an unknown name, or a value that is not a list, stops the proxy at startup and raises ValueError in the SDK. A surface left out records no checks.egress at all, and leaving out mcp also starves the coordination detector, which only /mcp feeds.

What counts as a destination

  • A string that begins with a URL, or is exactly an IP literal, under any argument name. Text after the URL does not matter ("https://evil.com/x please summarise" resolves to evil.com); text before it hides it ("see https://โ€ฆ" is not extracted).
  • A scheme-less host (api.example.com, or a single-label name like redis) only under one of these argument names: url, uri, host, hostname, endpoint, address, server, target, base_url, api_url, webhook. Under any other key โ€” callback_url, destination, forward_to โ€” a bare host is invisible: the call is not an egress attempt, passes under enforce and its destination is never recorded under observe. The gate exists because notes.txt or os.path look exactly like hostnames; if one of your tools names its destination something else, that destination is not governed. The converse matters under enforce: whatever sits under those names is treated as a destination โ€” target: "production" becomes the host production, and a postal address or a structured value such as server: {host: โ€ฆ, port: 443} cannot be resolved and is refused, even when the host inside it is allowlisted.
  • Anything undeterminable is refused under enforce: a destination-named argument whose value cannot be resolved (a ${TARGET} placeholder, a non-string, a file:// URI or a data: URI โ€” neither has a host), and โ€” for any call, even one with no destination at all โ€” arguments nested deeper than the governance scan depth (SCAN_DEPTH, 32 levels), counted from the JSON-RPC params (which wrap arguments); any object or array past that depth, even an empty one, cuts the egress walk short the same way. Since v0.13.1 the egress walk goes as deep as the firewall and PII walks; in 0.13.0 it stopped at 6 levels, so under enforce a tool call nested 7 to 31 levels deep was scanned by the firewall and still refused here. No allowlist entry can admit these. The depth refusal outranks any destination found higher up โ€” an allowlisted host at the top does not buy passage for one buried below the limit โ€” and is explicit: the reason reads "unresolvable destination: arguments nested past the scan depth limit" and the forensic record carries evidence.scan_truncated: true.

Each call is also classified as payload-bearing (write_shaped) when it carries a truthy value under body, data, payload, json, content, text or params, a URL query string of at least 16 characters, or any other plain string of 16+ characters โ€” on /mcp that includes the tool name itself, so a tool called search_documentation makes every call that names a destination payload-bearing. The flag never changes the allowlist decision; it gates the write quarantine and the coordination detector below. A search API that takes a query string counts as payload-bearing too โ€” declare such tools under read_only_tools if that matters.

Coverage is not uniform

The stage runs on up to five governed surfaces (four surfaces names), but only one of them passes tool-call arguments. In practice it is an MCP control:

SurfaceWhat the stage readsWhat it can see
POST /mcp ยท mcpthe JSON-RPC params of every method (for tools/call, name and arguments)destinations, as designed โ€” a resources/read of a file:// URI is refused under enforce
POST /v1/chat/completions ยท gatewaythe chat messages only โ€” since v0.13.0 the firewall scans the whole body, but this stage still does not read tools or other fieldsa message that begins with a URL, and every image_url part โ€” inline base64 (data:) images are always refused under enforce
POST /api/v1/validate ยท integrationthe content stringonly a content that begins with a URL or is an IP literal
GovernedModel.ask() / .stream() ยท sdkthe promptonly a prompt that begins with a URL or is an IP literal
GovernedAgent.call()โ€”no egress control at all

GovernedAgent carries tool-call-shaped params, but it reimplements the governance sequence inline instead of calling the shared pipeline, so an agent using it reaches any destination it likes, in enforce mode included. More limits that follow from where Admina sits: it sees the arguments, not what the tool composes and actually dials (a URL assembled from parts is not covered), and an agent with its own network access never traverses Admina at all. The allowlist is resolved once โ€” at proxy startup, or on a GovernedModel's first call โ€” so an admina.yaml edit needs a restart or a new instance. Full list in MODEL_CARD.md ยง5b.

Observe first, then build the allowlist

# 1. Run in observe (the default) with a persistent forensic backend
export FORENSIC_BACKEND=filesystem
export FORENSIC_BASE_DIR=/var/lib/admina/forensic

# 2. After a representative window, print a candidate allow: block
#    (--since is in days; --forensic-dir overrides the location)
admina egress suggest-allowlist --since 7

# 3. Review every entry, paste the block into admina.yaml, then:
export ADMINA_EGRESS_MODE=enforce

suggest-allowlist reads the checks["egress"] destinations out of local forensic records (--forensic-dir, else $FORENSIC_BASE_DIR, else .admina/forensic) and prints a ready-to-paste admina.yaml block; promoting an entry stays a human decision. --since takes a whole number of days (default 7). It reads a local directory only โ€” records kept by the s3 backend have to be copied down first. Two things narrow what it can see: the default FORENSIC_BACKEND=memory persists nothing, and only /mcp and the gateway write forensic records โ€” destinations seen by /api/v1/validate or the SDK are returned to the caller and then gone. --since filters on file modification time, so records freshly copied from S3 all count as recent.

On the Docker Compose stack, run the admina egress commands inside the proxy container: Redis is not exposed to the host, the forensic records live at /app/.admina/forensic in the container, and the image ships the package without the admina console script โ€” docker compose exec proxy python -m admina.cli.main egress suggest-allowlist.

Cross-agent coordination detector (v0.12.0)

Finds agents writing to a destination the operator never declared as shared. It runs in two phases on the payload-bearing MCP tool calls that reach the egress stage and are allowed by it โ€” a call the firewall or loop breaker already stopped is never counted:

  1. Fan-in trigger (always on). Counts distinct agent ids writing to one destination within fanin.window_seconds (default 3600, effectively one to two windows). Reaching fanin.min_agents (default 5, floor 2) raises a suspected verdict โ€” logged and recorded, nothing refused.
  2. Echo confirmation (keyed). Only when ADMINA_EGRESS_FINGERPRINT_KEY is set: each payload value is reduced to keyed HMAC hashes of 5-word shingles and compared with what other agents already sent to the same destination. Text that min_agents โˆ’ 1 other agents have already sent there (a fleet's header block or instruction preamble) is discounted first, and so is text the reading agent has sent itself. A match โ€” at least 12 shared shingles and 0.4 containment โ€” escalates to confirmed and quarantines the destination for writes, fleet-wide.

The quarantine only refuses calls under ADMINA_EGRESS_MODE=enforce โ€” the same mode gate as the allowlist. Under the default observe it is recorded, logged and listed, and the call proceeds. Under enforce it costs every agent write access to that destination (reads are never refused) until quarantine_ttl_seconds (default 86400) lapses or an operator lifts it. Every /mcp call that names the destination renews it โ€” reads and refused writes included โ€” so a fleet that keeps trying keeps it held:

# both read the quarantine set from Redis (--redis-url, default $REDIS_URL)
admina egress quarantine list                      # what is held
admina egress quarantine lift wiki.corp.internal  # clear one
domains:
  agent_security:
    egress:
      # exact lowercase hostnames โ€” no *.suffix, no CIDR
      coordination_declared:
        - queue.internal
      fanin:
        window_seconds: 3600
        min_agents: 5
      quarantine_ttl_seconds: 86400

Every confirmed, suspected or degraded verdict writes its own forensic-chain record (marked QUARANTINE for confirmed, OBSERVE otherwise), emits a policy_violation bus event and increments admina_coordination_verdicts_total{status="โ€ฆ"} on /metrics.

Read before enabling enforce. The detector finds undeclared coordination, not malicious coordination: a shared work queue produces the same signature as a covert channel, and coordination_declared is the only thing that tells them apart. Agent identity is the X-Agent-Id header, which the proxy does not authenticate โ€” calls without it all count as one agent, unknown โ€” so five requests carrying five made-up ids, two of them sharing one text, can arm a fleet-wide write quarantine on a destination: a denial of service under enforce, and the reason observe is the default. Without the fingerprint key the detector never escalates past suspected (it does not fall back to unkeyed, dictionary-attackable hashes). Without Redis there is no detection at all: every payload-bearing MCP call to an undeclared destination reports degraded, not none โ€” and admina dev local mode runs with no Redis by default. Only the MCP proxy feeds it โ€” the gateway, /api/v1/validate and the SDK do not โ€” and since v0.13.0 it is built only when the proxy serves the mcp surface (ADMINA_ENABLED_SURFACES) and egress runs on it. Detection is retrospective: the first calls that establish the fan-in count are never flagged. And it is not free: every recorded verdict is a second forensic record for that call, so past the fan-in threshold โ€” or from the first payload-bearing call when Redis is missing โ€” the per-call forensic write roughly doubles. Full limitations in MODEL_CARD.md ยง5c.

Proxy governance

The full security pipeline applies to all agent-to-agent calls transiting the proxy โ€” not just user-facing requests. Every hop is inspected.

When a pluggable guard raises an exception, the default is to skip it and record an ERROR check; setting ADMINA_GUARD_FAIL_MODE=closed (values open | closed, default open) turns that exception into a BLOCK โ€” see Configuration for the exact scope.

Risk levels

risk_level is not a block threshold. In enforce mode the pipeline branches on whether the firewall matched anything โ€” a single MEDIUM obfuscation pattern blocks exactly like a CRITICAL jailbreak. What the level is for is triage and alert routing: it is the highest severity among the patterns that matched (or the level the deep-path score maps to, if higher), and it travels on the response, the audit event and the forensic record.

LOWReported when nothing matched โ€” and for any custom_patterns or pack pattern you declare at that level, which still blocks
MEDIUM3 patterns โ€” base64 and ROT13 obfuscation markers, plus the "what are your instructions" phrasing of prompt extraction. Also the default of a custom_patterns entry with no risk_level
HIGH26 patterns โ€” all 11 of the Italian baseline (since v0.13.0), plus role hijack, chained override verbs ("Ignore. Forget. Override."), prompt extraction, data exfiltration, <system>- and ### system:-style delimiter injection, hex-escape obfuscation, internal-API tool abuse
CRITICAL15 patterns โ€” direct instruction override and its IT/FR/ES/DE equivalents, named jailbreaks (DAN & co.), special-token delimiter injection, destructive or shell tool abuse

The table covers firewall patterns. An egress refusal under enforce sets its own level: HIGH for a destination off the allowlist or one that cannot be determined, CRITICAL for a write to a quarantined destination.

There is no session-flagging mechanism: a block is per-request, and the session is only interrupted by the loop breaker's circuit breaker. A forensic record is written for every governed request, not only for flagged or critical ones: one per /mcp request, and two per gateway chat completion since v0.13.0 (gateway_request, then gateway_response once the response has ended). /api/v1/validate writes none.

D4

Compliance

Python + Rust (sha2)

Multi-regulation compliance tooling โ€” EU AI Act classification & gap analysis, NIS2 Art. 21(2) self-assessment, GDPR Art. 30 RoPA registry & Art. 35 DPIA scaffold, plus a hand-curated cross-regulation matrix. Backed by a SHA-256 hash-chained forensic black box and native OpenTelemetry spans.

Decision-support, not legal advice. Admina's compliance modules are self-assessment aids. A passing score in gap_analysis() does not constitute legal compliance and cannot replace conformity assessment under EU AI Act Art. 43, designated NIS2 authority audit, or GDPR DPO review. See the MODEL_CARD for scope, limitations, and known failure modes of every component.

EU AI Act

Automated risk classification under Article 6 and gap analysis against Articles 9โ€“15. Risk categories: unacceptable, high, limited, minimal.

UNACCEPTABLEBanned systems (social scoring, real-time biometrics in public, non-consensual deepfakes / synthetic CSAM โ€” added by Omnibus VII, effective 2 Dec 2026)
HIGHCritical infrastructure, employment, education, law enforcement
LIMITEDChatbots, emotion recognition โ€” transparency obligations apply
MINIMALSpam filters, games โ€” no additional requirements

Italian, French and German descriptions (v0.13.0). classify_risk() also matches the phrases of admina.domains.compliance.ai_act_terms โ€” Art. 5 practices, the Annex III areas and the Art. 50 cases in it, fr and de โ€” on whole words of a normalised text (lower case, accents removed), so a description of a CV-screening system in Italian is high rather than minimal. The result adds matched_terms (language, risk, area, term) and matched_areas. EUAIActCompliance(term_languages=[โ€ฆ], extra_terms={โ€ฆ}) narrows the languages and adds terms of your own. English keyword matching and its results are unchanged; a non-English description can now get a higher class than in 0.12. The lists propose a class โ€” a person confirms it (Art. 6(3)).

Timeline โ€” Omnibus VII (Council & Parliament agreement, 7 May 2026)

The Omnibus VII agreement postponed several high-risk deadlines, reduced the Art. 50 transparency grace period, and added a new Art. 5 prohibition. Admina's EU_AI_ACT_DEADLINES table mirrors the agreed timeline.

ObligationOriginal dateEffective date
Art. 5 โ€” prohibitions2025-02-02in force
Art. 50โ€“55 โ€” GPAI obligations2025-08-02in force
Art. 50 โ€” synthetic-content transparency2026-08-02 + 6m2026-12-02
Art. 5 โ€” NCII / synthetic CSAM (NEW)โ€”2026-12-02
Annex III high-risk systems2026-08-022027-12-02
National regulatory sandboxes2026-08-022027-08-02
Annex I high-risk (in products)2027-08-022028-08-02
Full applicationโ€”2028-08-02

Source: Council press release, 7 May 2026. Formal adoption expected before 2 August 2026.

NIS2 โ€” Article 21(2) self-assessment

Deterministic checklist of 10 measure areas ร— 4 controls = 40 checks mirroring the technical and organisational measures required by NIS2 Art. 21(2): policies on risk analysis and information system security; incident handling; business continuity (backup, disaster recovery, crisis management); supply chain security; security in network and IS acquisition, development, and maintenance; policies and procedures to assess effectiveness of measures; basic cyber hygiene practices and cybersecurity training; policies and procedures regarding the use of cryptography; human resources security, access control policies, asset management; multi-factor authentication and secure communications. Gap analysis surfaces unmet controls.

Endpoints: GET /api/compliance/nis2/areas ยท POST /api/compliance/nis2/assess.

GDPR โ€” RoPA & DPIA

Two GDPR components ship with Admina:

  • Article 30 RoPA registry โ€” typed CRUD over Records of Processing Activities. In-memory by default: the config loader has no gdpr section, so the commented ropa_path key in admina.yaml.example is inert. Persistence to a JSON file on disk is opt-in via the ADMINA_GDPR_ROPA_PATH environment variable, or by passing storage_path= when you construct ProcessingActivitiesRegistry yourself. If the path is not writable the registry logs a warning and stays in-memory.
  • Article 35 DPIA template โ€” generates a Markdown scaffold from operator-supplied facts, ready for legal review.

Endpoints: GET / POST / PUT / DELETE /api/compliance/gdpr/records[โ€ฆ] ยท POST /api/compliance/gdpr/dpia/template.

Cross-regulation matrix

Hand-curated mapping of 12 operational controls across EU AI Act, NIS2, and GDPR โ€” single source of truth for controls that satisfy multiple frameworks at once. Useful to avoid duplicate audit work; constant, no configuration. Surfaced at GET /api/compliance/matrix.

Forensic Black Box

SHA-256 hash chain with WORM semantics. Each record links to the previous hash, making any modification immediately detectable. Three backends are available โ€” see Configuration โ†’ Forensic backends for the comparison and the production recommendation.

Since v0.13.0 the filesystem store writes each record, _chain_state.json and its signature atomically and durably (a temporary file in the same directory, fsynced, renamed into place, then the directory fsynced). A record that cannot be written is not counted: the next one takes its sequence number, so the stored chain has no gap. What happens to the request then is ADMINA_FORENSIC_FAIL_MODE: open (default) logs the failure and serves the request, closed answers 503 without forwarding it. A filesystem or s3 backend that cannot be opened no longer falls back to the in-memory store: the proxy starts with a store that records nothing (forensic_writable: false on /health) in open mode, and does not start in closed mode. Details in Configuration.

Signed records and chain state

Set ADMINA_FORENSIC_STATE_KEY (or ADMINA_FORENSIC_STATE_KEY_FILE, which is refused when the file sits inside the forensic directory) and the store signs two things with HMAC-SHA256:

  • Every record (since v0.13.0): record_sig is the HMAC of the record's record_hash under a key derived from the state key, and record_sig_alg is hmac-sha256. Without a key a record carries record_sig_alg: "none" and no signature. With a key, verification checks every signature and requires one on every record written since the key was set.
  • The chain state (since v0.11.0): the chain head and record count are cached in _chain_state.json (or a bucket-root object) so a restart does not re-read every record, and its exact bytes are signed into a _chain_state.json.sig sidecar. This covers ForensicBlackBox on both its filesystem and S3 backends; the built-in FilesystemForensicStore plugin signs its chain state the same way but does not sign records.
What happens at startup (v0.13.0). A chain state that is missing while records exist, or whose signature does not verify, is rebuilt only from verified records: every stored record must verify with the key from record 1 on (sequence, hashes, links, signatures). The rebuild is logged at CRITICAL, written to the chain as a signed chain_state_rebuilt record, and kept in the chain state, so GET /health reports forensic_chain: "rebuilt" โ€” across restarts โ€” until an operator runs admina forensic acknowledge-rebuild (proxy stopped), which verifies the whole chain again and clears it. When such a state cannot be rebuilt โ€” there is no key, or a record does not verify โ€” or when the last record of a valid state is missing or differs, the chain is invalid: no record is written until the store is restored or moved aside and the process restarted, /health reports status: "degraded", and in closed mode governed requests are answered 503. Up to 0.12 a store without a key reconstructed its state from whatever records it found; a key is now what lets a damaged chain state recover at all. Keep the key with the store: since v0.13.1 a store that already holds signed records refuses to start without its key under ADMINA_FORENSIC_FAIL_MODE=closed (ForensicKeyError); under open it logs a warning at every start and keeps writing unsigned records (record_sig_alg: "none"), which later fail keyed verification with reason unsigned.

Embedding Admina directly rather than running the proxy? ForensicBlackBox accepts the key as state_signing_key: str | None = None, which takes precedence over the environment, and the fail mode as fail_mode="open" | "closed" (closed raises ForensicWriteError):

from admina.domains.compliance.forensic import ForensicBlackBox

# signing_key comes from your own secret manager โ€” never hardcode it
box = ForensicBlackBox(filesystem_dir="/var/lib/admina/forensic", state_signing_key=signing_key)

Record structure

{
  "sequence_number": 1024,
  "timestamp_utc": "2026-05-21T14:23:01.442331+00:00",
  "timestamp_unix_ms": 1779373381442,
  "previous_hash": "9c12...",
  "event": {
    "event_id": "3f2a9c14-5b7e-4d61-9a03-2c8f1e4b7d55",
    "event_type": "mcp_request",
    "agent_id": "openclaw-agent",
    "session_id": "sess_abc123",
    "method": "tools/call",
    "action": "allow",
    "risk_level": "low",
    "governance_latency_ms": 0.42,
    "checks": {"loop_breaker": {...}, "firewall": {...}, "pii_redaction": {...}},
    "would_action": null
  },
  "record_hash": "a3f8...",
  "record_sig": "e479...",
  "record_sig_alg": "hmac-sha256"
}

Everything the proxy submits lives under event; the surrounding keys are the chain metadata added by ForensicBlackBox.record(). Hashes and signatures are bare lowercase hex digests โ€” no sha256: prefix. record_hash is computed over the record without record_hash, record_sig and record_sig_alg. A record whose JSON repeats a key does not verify.

would_action is the shadow decision recorded when ADMINA_GOVERNANCE_MODE is observe or dry-run โ€” the action the pipeline would have taken before the mode downgraded it to ALLOW. The key is always present in the event object of the /mcp record; its value is "block" or "circuit_break" in those modes, and null under enforce mode and on clean traffic. Before v0.11.0 the shadow decision reached only the ClickHouse analytics record, leaving the hash-chained audit trail without it. Since v0.13.0 the gateway's gateway_request record carries it too, but only when observe or dry-run actually downgraded a decision โ€” the key is absent otherwise, clean traffic included โ€” and in upper case ("BLOCK", "CIRCUIT_BREAK").

ForensicBlackBox.recent_records() (since v0.12.2) keeps the last 1,000 records written by the running process in memory, with every backend; the dashboard feed reads them when no ClickHouse is configured. Records written before a restart are not read back.

Chain verification

Chain integrity is checked programmatically via ForensicBlackBox.verify_chain(last_n=0, *, from_seq=None, checkpoint=None). It is a coroutine, so it must be awaited; it reads the records back from the configured backend itself โ€” one at a time, in sequence order, in constant memory, on a worker thread โ€” rather than taking them as an argument (verify() is the synchronous form). Pass last_n > 0 to verify only the tail, or โ€” since v0.13.0 โ€” from_seq, or the checkpoint (sequence_number, record_hash) a previous run returned, to verify only what was written after it.

import asyncio
from admina.domains.compliance.forensic import ForensicBlackBox

box = ForensicBlackBox(filesystem_dir="/var/lib/admina/forensic")
result = asyncio.run(box.verify_chain())
print(result)
# {'valid': True, 'records': 1024, 'reason': None, 'sequence_number': None,
#  'checkpoint': {'sequence_number': 1024, 'record_hash': 'a3f8...'},
#  'signed': 1024, 'unsigned': 0, 'signatures_verified': True, 'last_hash': 'a3f8...'}

Since v0.13.0 sequence numbers must be contiguous from 1. On failure, sequence_number names the first bad record and reason says why: hash_mismatch, link_broken, missing_record, sequence_gap, state_mismatch, checkpoint_mismatch, state_missing or state_invalid (the chain state), and with a key signature_invalid or unsigned. The same check backs GET /api/v1/forensic/verify (which takes from_seq or checkpoint=SEQ:HASH โ€” see the API reference). For a filesystem store, the CLI reads the directory (--dir, default $FORENSIC_BASE_DIR) and writes nothing to it, so it can run while the proxy writes; admina doctor uses the same read-only check:

# exit 0 when valid, 1 when not; checks signatures when ADMINA_FORENSIC_STATE_KEY is set
admina forensic verify --checkpoint 1024:a3f8...
# the records from N on, one per line, each the exact bytes of its file
admina forensic export --from-seq 1 --format jsonl --out chain.jsonl

To verify a list of records you already hold, use the synchronous verify_records(records), which checks hashes and links only (no signatures, no sequence contiguity) and returns {"valid": bool, "checked": int} (plus "error" when invalid).

OpenTelemetry Integration

Native OTEL spans for all governance decisions. Exports to OTLP gRPC on port 4317. Every domain action is recorded as a span attribute โ€” no code changes required in your agent.

Since v0.13.0 the governance.decision event of each governed request โ€” /mcp, the gateway and /api/v1/validate โ€” carries names, counts and hashes, never request text: surface, event_id, domain, latency_us, categories (firewall category names), pii_count, request_sha256 and, when observe or dry-run downgraded a decision, would_action (upper case). The OpenTelemetry exporter writes each key as a span attribute admina.meta.<key>; the dashboard live feed and the alert channels (one alert per block or circuit break) read the same metadata. Exceptions raised while a request is governed are logged by class name only, since their message can quote the governed text. OTEL_ENABLED=false or ADMINA_OFFLINE=true turns the exporter off, so no telemetry connection is made.

OISG Adequacy Score

The full Docker Compose stack ships OISG adequate out of the box; a bare pip install scores 75, in the Good coverage band. The adequacy score (Open ยท Intelligent ยท Secure ยท Governed, 0โ€“100) is computed automatically from the live runtime state โ€” no manual checkboxes โ€” and is surfaced on the dashboard alongside the Admina Score.

curl http://localhost:8080/api/dashboard/oisg \
  -H "X-API-Key: $ADMINA_API_KEY"
# {"total": 85, "level": "OISG adequate", "pillars": {...}}

Since v0.13.0 compute_oisg_score_from_evidence(evidence) (admina.domains.compliance) scores the same 20 criteria from evidence you supply โ€” a status (satisfied, partial, accepted_gap with a reason, or not_applicable) and an evidence reference per criterion โ€” for deployments whose controls Admina cannot observe from its own runtime.

โ†’ Full paradigm, pillars, and Admina โ†” OISG mapping: OISG Adequacy.