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.
Data Sovereignty
Rust + spaCy NER ยท Presidio optionalEnsures 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.
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.
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.
admina.yaml under the domains.ai_infra section.
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_tagslists 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 (
€,€) no longer count as encoding markers; only\uXXXXescape sequences do (percent-encoding never counted). - The length signal starts above 100,000 characters (it was 2,000).
INJECTION_DEEP_PATH_ENABLED=falseturns the deep path off on either engine: a text is then flagged by the patterns only.heuristic_thresholdandallowed_tagsapply 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] 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 severityobserveNever block; log "would have blocked" for review and FP tuningdry-runLike observe + tag the response so downstream tools know the request was analysedScan 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 toevil.com); text before it hides it ("see https://โฆ"is not extracted). - A scheme-less host (
api.example.com, or a single-label name likeredis) 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 underenforceand its destination is never recorded underobserve. The gate exists becausenotes.txtoros.pathlook exactly like hostnames; if one of your tools names its destination something else, that destination is not governed. The converse matters underenforce: whatever sits under those names is treated as a destination โtarget: "production"becomes the hostproduction, and a postaladdressor a structured value such asserver: {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, afile://URI or adata: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-RPCparams(which wraparguments); 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 underenforcea 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 carriesevidence.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:
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 enforcePOST /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 enforcePOST /api/v1/validate ยท integrationthe content stringonly a content that begins with a URL or is an IP literalGovernedModel.ask() / .stream() ยท sdkthe promptonly a prompt that begins with a URL or is an IP literalGovernedAgent.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:
- Fan-in trigger (always on). Counts distinct agent ids writing to one
destination within
fanin.window_seconds(default 3600, effectively one to two windows). Reachingfanin.min_agents(default 5, floor 2) raises asuspectedverdict โ logged and recorded, nothing refused. - Echo confirmation (keyed). Only when
ADMINA_EGRESS_FINGERPRINT_KEYis 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 thatmin_agents โ 1other 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 toconfirmedand 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_ on
/metrics.
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 blocksMEDIUM3 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_levelHIGH26 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 abuseCRITICAL15 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.
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.
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 enforcementLIMITEDChatbots, emotion recognition โ transparency obligations applyMINIMALSpam 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.
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
gdprsection, so the commentedropa_pathkey inadmina.yaml.exampleis inert. Persistence to a JSON file on disk is opt-in via theADMINA_GDPR_ROPA_PATHenvironment variable, or by passingstorage_path=when you constructProcessingActivitiesRegistryyourself. 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_sigis the HMAC of the record'srecord_hashunder a key derived from the state key, andrecord_sig_algishmac-sha256. Without a key a record carriesrecord_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.sigsidecar. This coversForensicBlackBoxon both its filesystem and S3 backends; the built-inFilesystemForensicStoreplugin signs its chain state the same way but does not sign records.
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.