Skip to content

v0.13.0

Choose a tag to compare

@github-actions github-actions released this 05 Oct 22:14
· 39 commits to main since this release
2e5d8b9

[0.13.0] — 2026-10-05

Minor release: an OpenAI-compatible gateway for embedded deployments
(named upstream routes with keys, streams passed through unchanged,
upstream errors propagated, request limits, the governance pipeline in
worker threads, the governance outcome on every response, request ids and
W3C trace context, request and completion records with hashes, the whole
chat completion body in the scan), secrets from files, ADMINA_CONFIG,
ADMINA_ENABLED_SURFACES, the proxy-minimal extra and an offline mode,
linear-time pattern matching, signed release images with a -slim
variant, egress checks per surface, a forensic store that writes
atomically, signs each record, verifies from a checkpoint and exports JSON
Lines, PII engines from other packages with value-only redaction and an
[OMISSIS] mask style, per-surface request metrics and governance events
without request text, stable firewall pattern ids with pattern packs and
Italian baseline patterns, a schema check of admina.yaml, the engines in
use on /health, an OISG score from external evidence, admina redteam on
external corpora, a scan depth of 32 levels with a block past it on every
surface, EU AI Act classification of Italian, French and German
descriptions, a versioned ruleset document, and an upgrade guide
(docs/guides/upgrade-0.13.md). Upgrading is recommended; read the guide
first, since several defaults and failure modes change.

Security

  • Firewall patterns match in linear time on long inputs. Categories, risk
    levels and matching results are unchanged.

  • PII redaction and the spaCy + regex PII engine match e-mail addresses in
    linear time on long inputs. Detected spans are unchanged.

  • Each forensic record is signed: record_sig is the HMAC-SHA256 (64
    lowercase hex characters) of the ASCII characters of its record_hash,
    under a key derived from the chain-state key (ADMINA_FORENSIC_STATE_KEY
    or _FILE): HMAC-SHA256 of admina-forensic/1 record signature under that
    key; record_sig_alg is hmac-sha256. A record written without a key has
    record_sig_alg: "none" and no record_sig. record_hash is the SHA-256
    of json.dumps(record, sort_keys=True, default=str) of the record without
    record_hash, record_sig and record_sig_alg
    (forensic_integrity.HASH_EXCLUDED_FIELDS); for a record without the two
    signature fields it is computed as before. Verification with the key
    (the store's own, or state_key of verify_directory(), or the key in
    the environment of admina forensic verify) checks every signature
    (reason signature_invalid) and requires one from the chain state's new
    signed_from on (reason unsigned); it reports signed, unsigned and
    signatures_verified. Records written before a key was set are reported
    as unsigned. record_signing_key() and sign_record_hash() are in
    admina.domains.compliance.forensic_integrity.

  • ADMINA_FORENSIC_STATE_KEY_FILE inside the forensic directory is refused
    (SecretFileError): the key that signs the chain state and the records is
    kept outside the store.

  • The forensic chain state is rebuilt only from verified records. At
    startup a chain state that is missing (with records) or whose HMAC does
    not verify is rebuilt only when every stored record verifies with the key
    from record 1 on (sequence, hashes, links, signatures); the rebuild is
    logged at CRITICAL and recorded as a signed record of type
    chain_state_rebuilt (EventType.CHAIN_STATE_REBUILT, with cause,
    records_verified, head_hash). Without a key, or when a record does not
    verify, or when the last record of a valid chain state is missing or
    differs, the chain is invalid: chain_status is invalid, a CRITICAL
    log names the reason and the record, no record is written (in closed
    mode governed requests are answered 503), and verification is never
    valid. A record found after the last saved chain state is counted only
    when it verifies and links to it.

  • Verification requires contiguous sequence numbers from 1: a record
    missing before the first one found, between two records or before the
    chain state's count is reported as missing_record, and a sequence
    number that does not match its file, comes twice or out of order as
    sequence_gap. verify_directory() also checks the chain state's HMAC
    with the key and reports state_missing and state_invalid;
    verify_bucket() does the same for an S3 bucket, and admina doctor
    uses it, writing nothing.

  • GET /health reports forensic_chain: ok, rebuilt, invalid (then
    status is degraded), or null without a stored chain.

  • A record signature is valid only as 64 lowercase hex characters: any
    other record_sig is reported as signature_invalid, with or without the
    key. The HMAC sidecar of the chain state (_chain_state.json.sig, white
    space around it left out) is read as 64 lowercase hex ASCII characters;
    any other content, text that is not ASCII or bytes that are not UTF-8
    included, is an invalid chain-state signature (state_invalid; at
    startup the state is then rebuilt from verified records, or the chain is
    invalid). Signatures are compared in constant time as ASCII bytes
    (forensic_integrity.hex_digest_matches() and stored_hex_digest()), in
    the forensic store, verify_directory(), verify_bucket() and the
    built-in filesystem forensic store plugin.

  • POST /api/v1/audit stamps each record: source is always
    api_v1_audit (a source sent by the caller is kept as client_source)
    and submitted_by is the credential the request was admitted with
    (api_key, append_key, user:<id> for an auth provider's user, or
    unauthenticated). ADMINA_AUDIT_APPEND_KEY (or _FILE) is a key
    accepted by this route only, besides the API key; every other route
    refuses it. Unset (the default), the route needs the API key. An
    event_type of the records the proxy writes itself (mcp_request,
    mcp_response, gateway_request, gateway_response,
    gateway_response_scan, policy_violation, chain_state_rebuilt;
    integration.PROXY_RECORD_TYPES, compared without case and surrounding
    white space) is refused with 400, and nothing is recorded.

  • PII redaction reads text values and keeps the structure around them.
    _deep_redact (MCP tool parameters and results, GovernedAgent) passes
    the values of a dict to the PII engine and keeps its keys;
    redact_keys=True, and GovernedAgent(redact_keys=True), redacts the
    keys too. The gateway redacts the text of each chat message
    (redact_chat_params of admina.domains.governance, the new
    redact_params argument of run_pipeline): content, as a string or as
    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 request whose PII redaction masked text but
    returned no list of messages is blocked in every governance mode,
    answered as ADMINA_GATEWAY_BLOCK_STATUS says with
    X-Admina-Action: BLOCK, recorded with checks["pipeline"]
    ({"action": "ERROR", "error": "redacted_messages_missing"}) and logged
    as an error.

  • A mask of Admina already in the text (a placeholder: the mask of a
    category of PII_CATEGORIES, such as [IBAN] or [LOCATION], a category
    name in square brackets, such as [IP_ADDRESS], or [OMISSIS]) is not
    masked again: the NER step of the spacy-regex engine, the presidio
    engine and PIIEngineBridge mask a detected span only outside the
    placeholders. Other text in square brackets is masked like any other text
    (masking.placeholder_pattern()).

  • The presidio engine masks overlapping detections as one span, their
    union, with the category and mask of the first.

  • The governance.decision event of an /mcp request carries names,
    counts and hashes (admina.proxy.decisions.Decision): its metadata is
    surface, event_id (of the request's forensic record), domain (the
    part of the pipeline that decided), latency_us, categories (firewall
    category names), pii_count, request_sha256 (the SHA-256 of the
    JSON-RPC request as the proxy serialises it) and, in observe and
    dry-run mode, would_action. The dashboard live feed, the OpenTelemetry
    exporter (a span attribute admina.meta.<key> per key) and the alert
    channels read this metadata.

  • A blocked /mcp request sends one alert to each alert channel, built from
    its governance.decision event: details is the event's metadata.

  • An exception raised while a request or a response is governed on the
    gateway, /mcp or POST /api/v1/validate (by a governance guard, the PII
    engine, the pipeline or the upstream exchange) is logged by its class
    name, and at DEBUG with the frames of its traceback, without its message
    (admina.core.exception_log). The error of a guard's ERROR check
    (checks["guard_<name>"], request or response side, in the forensic
    records and the ClickHouse details) is the exception's class name. An
    /mcp request whose governance pipeline raises is answered 500
    (JSON-RPC -32603, Internal proxy error), and so is one that raises
    after the upstream answered; a POST /api/v1/validate request whose
    pipeline raises is answered 500 ({"detail": "Internal Server Error"}).

  • The firewall of the gateway scans every string of a chat completion
    request, keys included: the messages (content, names, tool calls), the
    tool definitions (tools), response_format and any other field of the
    body. The arguments of a tool call (and of a legacy function_call) are
    scanned as the JSON they hold, each string separately, and as they are
    when they are not JSON. ADMINA_GATEWAY_SCAN_ROLES and
    X-Admina-Scan-Policy narrow the messages only. A request whose body has
    a string nested more than 32 levels deep, or tool call arguments nested
    deeper than the JSON parser reads, is blocked in enforce mode
    (would_action in observe and dry-run), with checks.scan_depth = {"action": "BLOCK", "reason": "depth_limit_exceeded"} in its record.
    request_texts() of admina.domains.agent_security.scan_policy collects
    the texts and reports truncated; run_pipeline(scan_truncated=True)
    blocks.

  • ADMINA_GATEWAY_MODELS_ALLOWLIST applies to POST /v1/chat/completions
    too: a request for a model outside the list, or without a model, is
    answered 403 ({"error": {"message", "type": "invalid_request_error", "param": "model", "code": "model_not_allowed"}}) before any governance
    check, forensic record or upstream call. An empty list (the default) lets
    every model through.

  • The dashboard container of docker-compose.yml (dashboard/) adds
    ADMINA_API_KEY only to the dashboard's read-only routes
    (/api/dashboard/*, its live feed, /api/stats); /mcp and the other
    /api/ routes are forwarded as received, with the caller's own key. With
    ADMINA_API_KEY set the container does not start without
    ADMINA_DASHBOARD_PASSWORD (HTTP Basic Auth), and it refuses a key with
    characters other than letters, digits and . _ ~ + / = -. Without the
    key it sends no key header, and the dashboard page signs in with the key.
    Compose publishes the dashboard on 127.0.0.1:3000.

  • PII redaction builds the masked text in one pass over the detected spans
    (masking.replace_spans()), in the regex and NER steps of PIIRedactor,
    the presidio engine and the spaCy + regex PII engine: its time grows
    linearly with the length of the text, however many spans it masks. The
    masked text is unchanged.

  • A rebuilt forensic chain state stays visible. After the chain state
    was rebuilt from the records, forensic_chain was rebuilt until the
    next restart, then ok: removing the last records together with the
    chain state left no lasting trace. The chain state now keeps the rebuild
    (rebuilt: cause, record count, time) and /health reports rebuilt
    until it is acknowledged.

  • A forensic record whose JSON repeats a key does not verify
    (hash_mismatch). The hash was computed on the record as Python's parser
    reads it, which keeps the last of the repeated values, while another
    parser of an exported record can keep the first.

  • /mcp and /api/v1/validate refuse text they cannot scan. Their
    pipeline scanned and redacted strings down to 6 levels of nesting and let
    deeper text through unscanned and unredacted: an injection nested in five
    objects inside a tool call's arguments was allowed. They now scan and
    redact 32 levels deep, as the gateway does, and with the firewall or PII
    redaction on, a request holding text deeper than that is blocked in
    enforce mode (a would-be block in observe and dry-run), with
    checks.scan_depth = {"action": "BLOCK", "reason": "depth_limit_exceeded"}.
    admina.domains.governance.SCAN_DEPTH is the limit.

  • The Presidio PII engine (ADMINA_PII_ENGINE=presidio) asks the analyzer
    only for the entity types it maps to Admina categories. It ran every
    Presidio recognizer and discarded the other results; the URL recognizer
    took about 1.2 ms per character on text with many dots (80 seconds on
    64,000 characters). Detected spans are unchanged.

Added

  • Local make targets that mirror the CI jobs: make ci-local, make ci-linux
    and make ci-audit (see make help). make ci-linux runs its container on
    the CPUs in CI_LINUX_CPUS (default 0-3, the size of a hosted runner).

  • Pattern timing probe, admina.domains.agent_security.pattern_timing:
    probe_pattern() returns the worst search time of a regular expression on
    generated 64k-character inputs (trigger words followed by runs of spaces,
    tabs, commas or newlines, and repeated triggers); measure_pattern() also
    names the slowest input. Use it to check
    agent_security.firewall.custom_patterns before deploying them.

  • Named upstream routes for the OpenAI-compatible gateway.
    ADMINA_GATEWAY_UPSTREAMS (name=url[,name=url…]) or gateway.upstreams
    in admina.yaml (<name>: {url, api_key_file}) define the routes; the
    environment variable, when set, replaces the YAML routes.
    gateway.default_upstream names the route used when a request names none
    (default: the first route). A request selects a route with the
    X-Admina-Upstream header on POST /v1/chat/completions and
    GET /v1/models; an unknown route name gets a 400 response in the OpenAI
    error format (invalid_request_error, code unknown_upstream) before
    any governance check or forensic record. Without named routes the gateway
    has one route, default, to ADMINA_GATEWAY_UPSTREAM. The
    gateway_request forensic record carries the route name (upstream).

  • Upstream API keys for the OpenAI-compatible gateway, sent as
    Authorization: Bearer <key>: ADMINA_GATEWAY_UPSTREAM_API_KEY or
    ADMINA_GATEWAY_UPSTREAM_API_KEY_FILE for every route, overridden per
    route by ADMINA_GATEWAY_UPSTREAM_<NAME>_API_KEY[_FILE] (<NAME>: route
    name in upper case) or by the route's api_key_file. Key files are read
    once at startup, with one trailing newline removed. The proxy does not
    start when a key file is missing, unreadable or empty, when a key is set
    both directly and as a file, when a route is malformed or when
    default_upstream names no route. Keys are masked in the settings
    representation and are not logged. Without a key no Authorization
    header is sent. The caller's Authorization, X-API-Key, Cookie and
    X-Admina-Upstream headers are not forwarded upstream.

  • admina.core.secretfile: read_secret_file() and resolve_secret()
    resolve a secret setting given directly or as <SETTING>_FILE.

  • Request body cap on every route: ADMINA_MAX_REQUEST_BYTES (default
    10 MiB, 0 = no limit). A body over the cap gets 413 before it is
    parsed: at once when its Content-Length is over the cap, otherwise as
    soon as the bytes read go over it. The 413 body is in the OpenAI error
    format on /v1 (invalid_request_error, code request_too_large) and
    {"detail": ...} elsewhere.

  • Upstream timeouts and connection pool of the OpenAI-compatible gateway,
    which has an HTTP client of its own: ADMINA_GATEWAY_TIMEOUT_CONNECT and
    ADMINA_GATEWAY_TIMEOUT_READ (default 30 seconds),
    ADMINA_GATEWAY_TIMEOUT_TOTAL (the whole upstream exchange; default 0),
    ADMINA_GATEWAY_MAX_CONNECTIONS (default 100) and
    ADMINA_GATEWAY_MAX_KEEPALIVE_CONNECTIONS (default 20). A timeout of 0
    means no limit. A timeout before the response starts gets 504 and any
    other transport failure 502, with an OpenAI-style error body (type
    upstream_error, code upstream_timeout or upstream_error) that
    carries no exception text. A failure during a stream ends it with one
    data: {"error": ...} event and no data: [DONE].

  • ADMINA_GATEWAY_STREAM_MODE, or gateway.stream_mode in admina.yaml
    (the environment variable wins): passthrough (default) or governed.

  • ADMINA_GATEWAY_MAX_PROMPT_CHARS (default 0, no limit): the longest
    message text of POST /v1/chat/completions, in characters (the text of
    every message). A longer request gets 413 (invalid_request_error, code
    prompt_too_long) before any governance check. MAX_REQUEST_TOKENS
    applies to /mcp only.

  • admina.core.jcs.canonicalize(): the RFC 8785 (JSON Canonicalization
    Scheme) serialisation of a JSON value, as UTF-8 bytes.

  • ruleset_sha256() (admina.domains.agent_security.ruleset): the SHA-256,
    as 64 lowercase hex characters, of the RFC 8785 serialisation of
    {"admina_version", "engine", "builtin", "pattern_packs", "custom_patterns", "disabled_categories", "heuristic_threshold_milli"},
    an object of strings and integers only. builtin lists the active builtin
    patterns ({regex, category, risk_level}, in order, without those of a
    disabled category) for the python engine and is
    {"admina_core_version": ...} for the rust engine; custom_patterns
    are the entries as the firewall loads them; disabled_categories are
    sorted without duplicates; heuristic_threshold_milli is the threshold ×
    1000, rounded. The module imports neither FastAPI nor the proxy.
    agent_security.firewall.pattern_packs (a list of names) is read from
    admina.yaml and is part of the hash.

  • The proxy computes ruleset_sha256() at startup for the engine its
    firewall runs on. Every POST /v1/chat/completions response carries it in
    X-Admina-Ruleset, the 401 of authentication and the 413 of the request
    size limit included; an unexpected failure before the response starts gets
    a 500 in the OpenAI error format (code internal_error) with the header.
    GET /v1/admina/ruleset (API key required) returns it with engine,
    admina_core_version, admina_version, accepted_prescan_rulesets,
    prescan_tags, scan_roles and scan_policy_enabled.

  • Scan scope of the gateway. ADMINA_GATEWAY_SCAN_ROLES (default
    system,user,assistant,tool) sets the message roles the firewall scans;
    messages with any other role are always scanned. With
    ADMINA_GATEWAY_SCAN_POLICY_ENABLED=true (default false) a request can
    narrow the scan with X-Admina-Scan-Policy: v1; roles=user,tool; prescanned=source,document; ruleset=<sha256>: only the listed roles, and
    without the text of <tag …>…</tag> blocks of the listed tags that are
    also in gateway.prescan_tags of admina.yaml. The policy applies only
    when ruleset is the proxy's own or one in gateway.prescan_rulesets;
    otherwise, when the header is malformed, or while scan policies are off,
    the request is scanned in full. Unclosed, nested or stray tags leave the
    whole text to the scan. Any caller that holds the API key can send the
    header, so scan policies are for deployments where every such caller is
    trusted to scan what it declares (see the README). The gateway_request
    forensic record carries the outcome as prescan (accepted, status,
    roles, tags, ruleset), and /metrics counts
    admina_prescan_accepted_total, admina_prescan_ruleset_mismatch_total,
    admina_prescan_malformed_total and admina_prescan_ignored_total.

  • ADMINA_GATEWAY_PIPELINE_WORKERS (default 0, the number of CPUs): the
    worker threads that run the gateway's governance pipeline, the most
    requests governed at once. ADMINA_GATEWAY_PIPELINE_TIMEOUT (default 0,
    no limit): seconds a request waits for its governance decision, the wait
    for a thread included; past it the request is blocked in every governance
    mode and recorded with checks.pipeline (time_budget_exceeded). The
    same budget bounds the PII redaction of each completion and stream line.

  • admina_event_loop_lag_seconds on /metrics: a histogram of how late the
    event loop wakes up a task that sleeps 0.1 s at a time.

  • ADMINA_GATEWAY_SCAN_RESPONSE (default false): the firewall also checks
    the content of each choice of a chat completion. A non-streaming completion
    flagged in enforce mode, or whose check runs over the time budget, is
    replaced by the block message; a streamed completion is checked after it
    has been sent and the outcome is only recorded. Each check writes a
    forensic record of type gateway_response_scan, linked to the request
    record by request_event_id.

  • scripts/bench_gateway.py: time to the first chunk added by the gateway
    and event loop lag on a retrieval-augmented trace, per firewall engine.

  • ADMINA_CONFIG: the admina.yaml to load, for example
    /etc/admina/admina.yaml. When it is set, load_config() reads exactly
    that file, and so do the proxy, the firewall overrides, the egress policy
    and the PII engine selection; a missing, unreadable or invalid file raises
    admina.core.config.ConfigFileError and the proxy does not start. Unset
    or empty, the search in the current directory and in the package
    directory is unchanged. Explicit yaml_path and search_paths arguments
    still take precedence.

  • ADMINA_API_KEY_FILE and ADMINA_FORENSIC_STATE_KEY_FILE: files holding
    the API key and the forensic chain-state key, read once at startup with
    one trailing newline removed. A missing, unreadable or empty file, or a
    key set both directly and as a file, stops the proxy; the error names the
    setting and the path, not the key. The built-in filesystem forensic store
    plugin reads ADMINA_FORENSIC_STATE_KEY_FILE too.
    admina.core.secretfile.secret_from_env() resolves such a pair of
    environment variables.

  • ADMINA_ENABLED_SURFACES: the surfaces the proxy serves, comma-separated
    (empty = all): gateway (/v1/*), mcp (/mcp, /mcp/*),
    integration (/api/v1/*), compliance (/api/compliance/*) and
    dashboard (/api/dashboard/* with the live feed and the browser
    sign-in, /api/stats, /api/events, the dashboard shell). The routes of
    a disabled surface are not mounted and answer 404 before authentication;
    /health and /metrics are always served. An unknown surface name stops
    the proxy. The startup banner lists the enabled surfaces.

  • GET /health reports mode (governance mode), surfaces (enabled
    surfaces), ruleset_sha256 (the active firewall ruleset, as in
    X-Admina-Ruleset) and forensic_writable (filesystem backend: a probe
    file created, written, fsynced and removed in FORENSIC_BASE_DIR; s3:
    the result of the last record write, null before the first; memory:
    null). The write check runs at most once every 10 s, on a thread of its
    own; concurrent calls share it, and a check that takes longer than 1 s
    reports false. The other fields, engine included, are unchanged.
    Example (ADMINA_ENABLED_SURFACES=gateway, filesystem backend, Rust
    engine):

    {
      "status": "healthy",
      "service": "admina-proxy",
      "version": "0.13.0",
      "mode": "enforce",
      "surfaces": ["gateway"],
      "ruleset_sha256": "<64 hex>",
      "forensic_writable": true,
      "engine": {
        "engine": "rust",
        "rust_available": true,
        "rust_version": "0.13.0",
        "selection": "auto",
        "active": "rust",
        "pii_active": "python"
      },
      "timestamp": "2026-09-27T18:35:14.481520+00:00"
    }
  • ForensicBlackBox.writable(): the write check behind forensic_writable.

  • proxy-minimal extra: the proxy without Redis, ClickHouse, boto3, typer
    and the numpy/scikit-learn stack of the Python loop breaker. It serves
    the gateway surface (ADMINA_ENABLED_SURFACES=gateway, with REDIS_URL
    and CLICKHOUSE_HOST empty); with the mcp or integration surface
    enabled and neither proxy nor rust installed, the proxy does not
    start and says which extra to install.

  • ADMINA_LOG_FORMAT=json: one JSON object per log line (timestamp,
    level, logger, message, and exception when there is one),
    uvicorn's own lines included. Other record attributes are not written.
    text (default) keeps the current format.

  • ADMINA_METRICS_REQUIRE_AUTH and ADMINA_API_DOCS_REQUIRE_AUTH (default
    false): put /metrics, and /docs, /redoc, /openapi.json, behind
    the API key.

  • DASHBOARD_COOKIE_SECURE=auto: the dashboard session cookie is Secure
    over HTTPS and, over plain HTTP, whenever the dashboard is addressed by a
    host other than localhost, a *.localhost name or a loopback address.
    true and false (default) keep their meaning; the usual boolean
    spellings are accepted and any other value stops the proxy.

  • slim target of the proxy Dockerfile, published as
    ghcr.io/admina-org/admina-proxy:<version>-slim: the proxy extra and
    the Rust engine, without the nlp and telemetry extras and without the
    dashboard files.

  • The release images are pushed with an SBOM and a max-mode provenance
    attestation and signed with cosign, keyless through GitHub OIDC (the
    cosign verify command is in .github/workflows/release-docker.yml).
    They carry org.opencontainers.image.* labels, the proxy images also
    org.admina.engine=rust, and the LICENSE and NOTICE files in
    /usr/share/licenses/admina/.

  • Governance outcome headers on the responses of POST /v1/chat/completions
    once the request has its event id, streaming or not, upstream errors,
    timeouts and failures in the gateway included: X-Admina-Event-Id,
    X-Admina-Action (ALLOW or BLOCK), X-Admina-Risk,
    X-Admina-Categories (the names of the firewall categories that matched,
    comma-separated; never text) and X-Admina-Record-Hash (the record_hash
    of the request record, written before the request is forwarded);
    X-Admina-Would-Action in observe and dry-run mode. Every response of
    the route carries X-Admina-Version. A request body with a value JSON
    cannot encode for the upstream request (NaN, an unpaired surrogate) is
    answered 400 with "code": "invalid_request_body"; any other failure in
    the gateway 500 with "type": "server_error".

  • ADMINA_GATEWAY_BLOCK_STATUS: 200 (default: the block message as a
    completion) or 403 ({"error": {"message", "type": "governance_blocked", "param", "code": "governance_blocked", "categories"}}, streaming or not).

  • ADMINA_GATEWAY_REQUEST_ID_HEADER, ADMINA_GATEWAY_RECORD_HEADERS and
    ADMINA_GATEWAY_FORWARD_HEADERS: the header recorded as request_id, the
    headers recorded in context and the headers forwarded upstream (all empty
    by default). Credentials cannot be listed; nor, for forwarding, connection
    and body headers or X-Admina-*. Forwarded values outside ASCII are sent
    as the bytes received.

  • W3C trace context on the gateway: a valid traceparent is recorded as
    trace_id and, when listed, forwarded with tracestate. With
    OpenTelemetry on, each chat completion has a gateway.chat.completions
    span, a child of the caller's span.

  • gateway_request record fields: request_id, trace_id, context,
    request_sha256 (the SHA-256 of the RFC 8785 canonical form of the
    messages forwarded upstream; test vectors in
    tests/fixtures/jcs_vectors.json), ruleset_sha256, categories, and
    would_action in observe and dry-run mode.

  • A gateway_response forensic record (EventType.GATEWAY_RESPONSE) for
    each gateway chat completion, with the event_id of its request, written
    once the response has ended: response_sha256 (the bytes sent to the
    client, counted as a stream goes out), finish_reason, usage,
    duration_ms, status_code, upstream_status_code, cancelled and
    error (the exception class only).

  • admina.core.trace_context (W3C traceparent and tracestate parsing)
    and OTELGovernanceExporter.start_span().

  • agent_security.egress.surfaces in admina.yaml: the surfaces the egress
    stage runs on, among gateway (the text of the chat messages of
    POST /v1/chat/completions), mcp (the arguments of /mcp tool calls),
    integration (/api/v1/validate) and sdk (GovernedModel.ask() and
    stream()). Unset: 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. With gateway
    left out, the gateway does not evaluate the text of chat messages for
    destinations and its records have no checks.egress.
    admina.domains.agent_security.egress adds EGRESS_SURFACES,
    parse_egress_surfaces() and egress_policy_for().

  • ADMINA_GATEWAY_FORWARD_FIELDS, ADMINA_GATEWAY_MAX_N and
    ADMINA_GATEWAY_MAX_COMPLETION_TOKENS (all off by default: the body is
    forwarded as received): the top-level fields of a chat completion
    forwarded upstream (model, messages, stream and the fields of a limit
    that is set always are), the largest n, and the largest max_tokens and
    max_completion_tokens. Larger values are lowered to the limit, and a
    request that sets neither token field is forwarded with max_tokens set to
    it. While a limit is set, a value of its fields other than an integer of at
    least 1 (absent and null aside) is answered 400 ({"error": {"message", "type": "invalid_request_error", "param": <field>, "code": "invalid_value"}}) before any governance check, forensic record or
    upstream call. The firewall scans the request as received; the forwarded
    messages and request_sha256 do not change. admina.proxy.gateway_body
    holds ForwardSettings.

  • ForensicBlackBox(fail_mode=...): open (the default) logs a record, or
    a chain state after it, that cannot be written and record() returns
    stored: false with no sequence number or hash; closed raises
    ForensicWriteError. accepting_records() is false after a failed write
    until a write succeeds again.

  • Forensic chain verification reads one record at a time, in sequence
    order, in constant memory, on a worker thread. verify_chain() (and the
    synchronous verify()) take from_seq or a checkpoint
    (sequence_number, record_hash) to verify only the records after it, and
    return, besides valid, records and last_hash: reason and
    sequence_number (the first failure) and checkpoint (where to resume).
    Reason codes: hash_mismatch (a record is not a JSON object, or its
    record_hash is not the hash of its content), link_broken
    (previous_hash is not the hash of the record before it, GENESIS for
    record 1), missing_record (there is no record with the next sequence
    number: before the first one found, between two records, or before the
    chain state's count), state_mismatch (the record at the chain state's
    count is not its head) and checkpoint_mismatch (the record at the
    checkpoint's sequence number has another record_hash).
    admina.domains.compliance.forensic_integrity
    (compute_record_hash(), canonical_record(), verify_entries()) and
    admina.domains.compliance.forensic_files (record keys, atomic_write())
    are public.

  • ADMINA_FORENSIC_FAIL_MODE: open (default) or closed. In closed
    mode a request whose forensic record cannot be written is answered 503
    and not forwarded: the gateway with {"error": {"message", "type": "server_error", "param": null, "code": "forensic_unavailable"}}, /mcp
    with a JSON-RPC error (-32603), POST /api/v1/audit with 503;
    POST /api/v1/validate answers 503 until a record is written again; and
    a filesystem or s3 backend that cannot be opened at startup stops the
    proxy (ForensicBackendError). In open mode the failure is logged and
    the request served.

  • The proxy reads domains.compliance.forensic.backend (or its older name
    storage) and base_dir from admina.yaml when FORENSIC_BACKEND and
    FORENSIC_BASE_DIR are not set; the environment's values win, and a value
    set in both places with different values is logged at startup. An unknown
    backend in admina.yaml stops the proxy. admina.proxy.forensic_backend
    builds the store.

  • GET /api/v1/forensic/verify takes from_seq or checkpoint=SEQ:HASH
    (not both; a malformed value, or a SEQ of more than 19 digits, is
    answered 400) and verifies from there.

  • admina forensic export --from-seq N --format jsonl [--dir DIR] [--out FILE|-]: the records of a filesystem store from sequence number N on, in
    sequence order, one per line, each the bytes of its file followed by a
    newline. admina forensic verify [--from-seq N | --checkpoint SEQ:HASH]
    prints the verification result as JSON and exits with 0 (valid) or 1.
    Both read --dir (default $FORENSIC_BASE_DIR) and write nothing to it;
    so does verify_directory() of admina.domains.compliance.forensic, and
    admina doctor now uses it for the filesystem backend.

  • GET /health status is degraded while forensic records cannot be
    written (forensic_writable false, or the last record or chain-state
    write failed); healthy otherwise.

  • PII engines of other packages: get_pii_engine (ADMINA_PII_ENGINE,
    pii_engine in admina.yaml) looks a name up among the built-in engines,
    then among the entry points of the admina.pii_engines group, each naming
    a BasePIIEngine subclass or a callable that returns one (a config
    parameter receives the engine's plugin_config block). An unknown name
    raises ValueError listing the built-in and the registered engines, and
    the proxy does not start. admina.engines.PIIEngineBridge is the
    synchronous PIIBridge of a BasePIIEngine: it runs detect and
    redact on an event loop of the engine's own, from any thread, and
    returns redacted_text, entities (type, offsets, length, engine name;
    never the text), categories and count. BasePIIEngine gains
    special_categories, sentence_categories and sentences(text).

  • ADMINA_PII_MASK_STYLE (pii_mask_style in admina.yaml): typed (the
    default, each span replaced by the mask of its type) or omissis (each
    span replaced by [OMISSIS], in every engine, in requests, responses and
    streamed responses). In omissis, the sentence_categories of an engine
    have their whole sentence replaced (the engine's sentences, by default
    sentence_spans of admina.domains.data_sovereignty.masking), and
    StreamRedactor releases a stream from such an engine a whole sentence at
    a time, holding at most max_hold_chars (default 4096). Any other value
    raises ValueError.

  • DataClassifier classifies the special categories of personal data of
    GDPR art. 9 and 10 (SPECIAL_CATEGORIES) and those passed as
    special_categories (such as an engine's) as restricted; category
    names are compared case-insensitively.

  • ADMINA_PRESIDIO_NLP_MODELS (it:blank,en:en_core_web_sm) and
    presidio.nlp_models in admina.yaml: the spaCy pipeline of each
    language of the presidio engine, an installed model or blank (a
    tokenizer with no model and no NER). Unset, en_core_web_sm and
    it_core_news_sm are used when installed. A configured model that is not
    installed, or a malformed setting, stops the engine; models are never
    downloaded. get_presidio_pii_engine() keeps one engine per mask style
    and pipelines.

  • ADMINA_OFFLINE (default false): true sets HF_HUB_OFFLINE,
    TRANSFORMERS_OFFLINE and HF_DATASETS_OFFLINE to 1 before a PII engine
    is built and when the proxy starts, and the proxy starts without the
    OpenTelemetry exporter (OTELGovernanceExporter(enabled=False)). Values
    other than true/false (1/0, yes/no, on/off) raise ValueError.

  • The IBAN category of the spacy-regex engine covers the IBAN registry:
    an IBAN is masked when it has the length of its country (Italy: 27
    characters), compact or with single spaces, and a valid mod-97 checksum
    (admina.domains.data_sovereignty.iban). The PHONE category also covers
    Italian mobile and landline numbers, with +39, 0039 or without.

  • The gateway and POST /api/v1/validate record their governance decisions
    as /mcp does (admina.proxy.main.record_decision): each governed
    request emits one governance.decision event (live feed, OpenTelemetry,
    one alert per BLOCK or CIRCUIT_BREAK) and, with ClickHouse
    configured, stores one governance_events row (gateway_request, or
    validate_request, the new EventType.VALIDATE_REQUEST). A gateway
    request is recorded once its response has ended: its row has
    response_hash (the SHA-256 of the response sent), and a completion
    answered with the block message after the upstream answered is a BLOCK
    of domain response_firewall or response_pii. The event of a
    /api/v1/validate request has a new event_id, and the body's
    session_id without CR/LF, cut to 128 characters.

  • /metrics serves, for the gateway, /mcp and /api/v1/validate
    (surfaces gateway, mcp, integration):
    admina_request_duration_seconds{surface}, a histogram of the time from
    the arrival of a request to the end of its response, and
    admina_governance_duration_seconds{surface}, a histogram of the time
    the governance pipeline took (admina.proxy.request_metrics).

  • OTEL_ENABLED (default true): with the telemetry extra installed and
    ADMINA_OFFLINE off, the proxy exports its spans to OTEL_ENDPOINT, as
    before; false builds no exporter, so nothing is exported and no
    connection is made for telemetry.

  • Stable firewall pattern ids. Every builtin pattern has an id
    (firewall.BUILTIN_PATTERNS, firewall.BUILTIN_PATTERN_IDS):
    <category>.<language>.<n> for the categories of 0.12 (en for the
    English patterns, it, fr, es and de for multilang_evasion, for
    example instruction_override.en.1 and multilang_evasion.it.1) and
    <category>.<n> for the it_* categories. An id never changes and is
    never reused. custom_patterns entries get custom.<n>, in their order.
    Each entry of the fast path's patterns carries the id of the pattern
    that matched, so the forensic checks do too; InjectionFirewall.pattern_ids
    lists the ids a firewall applies.

  • agent_security.firewall.disabled_patterns: ids of patterns the firewall
    leaves out (Python engine; disabled_categories is unchanged). An id that
    names no pattern is logged as a warning and ignored; a value that is not
    a list of strings is a configuration error. Like custom_patterns, it
    makes get_firewall() use the Python firewall when the Rust engine is
    selected.

  • agent_security.firewall.heuristic_threshold sets the deep-path score
    from which the Python firewall flags a text (default 0.5, the value it
    used before; a value that is not a finite number greater than 0 stops
    get_firewall() with ValueError). INJECTION_DEEP_PATH_ENABLED=false
    turns the deep path off on either engine: check() then returns the
    fast-path result. get_firewall(deep_path_enabled=...) overrides the
    variable; the proxy passes its setting (read from the environment or
    .env).

  • agent_security.firewall.allowed_tags: tag names (any case) the deep
    path does not count as context switches, such as the tag an application
    puts around retrieved documents. Other tags, separators and code fences
    still count. Python engine; it does not select the Python firewall.

  • Firewall pattern packs (admina.domains.agent_security.pattern_packs):
    named, versioned sets of patterns in YAML or JSON ({name, version, description, patterns: [{id, regex, category, risk_level}]}; no other
    key; description optional) that the Python firewall adds after its
    builtin patterns when agent_security.firewall.pattern_packs lists them.
    A pack pattern's id is <pack>:<id> in check results and
    disabled_patterns. Each name is looked up among the entry points of the
    group admina.pattern_packs (a loader that returns the pack mapping or
    the path of a pack file in package data), then in the directories of
    agent_security.firewall.pattern_pack_dirs (<name>.yaml, .yml,
    .json), which ADMINA_PATTERN_PACK_DIRS (separated by os.pathsep)
    replaces when set. A pack not found (the error lists the available
    packs), found in two sources, listed twice or invalid (the error names
    the file or entry point and the key path), and a missing pack directory,
    raise PatternPackError: get_firewall() fails and the proxy does not
    start. Pack patterns are timed on 64k-character inputs when the firewall
    is built: one over 50 ms is logged as a warning with its id, or raises
    PatternPackError with agent_security.firewall.strict_pack_timing: true. A listed pack makes get_firewall() use the Python firewall.
    Example pack: examples/pattern_packs/example-pack.yaml.

  • Italian baseline of the Python firewall, four builtin categories, risk
    high: it_instruction_override (it_instruction_override.1: an
    override verb such as ignora, dimentica, non seguire where an
    instruction starts, with rules, instructions or "quanto detto" as object;
    .2: the same verbs with a second-person object, anywhere: "le tue
    istruzioni", "il tuo prompt", "quanto ti è stato detto"; .3: an
    override after a clause that starts, where an instruction starts, with a
    second-person imperative such as traduci, riassumi, rispondi,
    scrivi, then up to twelve words and a comma or e, ma, poi,
    quindi: "Traduci il testo e ignora le istruzioni precedenti"; the words
    contain no opening quote, bracket or tag, no table cell separator and no
    >, and an apostrophe only after a letter or digit ("l'articolo"), so an
    opening quote inside the clause ends it),
    it_role_hijack (.1: "d'ora in poi" / "da adesso" and a second-person
    verb; .2: "sei ora" an AI or an assistant without limits; .3: "agisci
    come" / "fai finta di essere" a model without filters; .4: "parla
    come" / "immagina di essere" a model without filters where an instruction
    starts), it_prompt_extraction (.1: rivela, mostra, ripeti ...
    the system prompt or "le tue istruzioni" where an instruction starts;
    .2: the second-person forms mostrami, dimmi ...) and
    it_model_addressing (.1: a note or instruction for an AI system
    followed by : ("Istruzioni per l'IA:"), or an AI system addressed
    directly followed by : or ! ("Attenzione chatbot:", "Attenzione
    IA!"); .2: "se sei un'intelligenza artificiale"). "Where an instruction
    starts" is the start of the text, after a sentence end, a colon, a line
    break, an opening bracket, a table cell separator |, an opening tag
    (<p>), the start or the end of an HTML comment (<!--, -->), or an
    opening quote or backtick (one that follows no letter or digit); then up
    to four closing tags, comment ends or speaker labels ("Nota:
    ignora ...", "

    Ignora ..." at the start of the text, "Utente>
    Ignora ..." at the start of a line), an optional list marker (-, *,
    –, 1), a), a # heading, a > quote), optional emphasis (**,
    __) and up to two words addressing the reader ("ok,", "ciao,",
    "grazie,", "ora", "poi", "per favore", "assistente,"). A closing quote,
    tag or emphasis after a word is not such a start ('Il modulo "Alfa"
    ignora le istruzioni precedenti', "Il fornitore ignora le
    istruzioni precedenti"); --> is one, also when it is written as an
    arrow (-> is not). An override inside a sentence without
    one of these contexts is not matched, for example "Il testo è finito e
    ignora le regole ricevute fin qui", or "Analizza il testo e ignora le
    istruzioni precedenti" (analizza has the form of the third person).
    Every pattern is timed with the builtin patterns
    (tests/test_firewall_pattern_timing.py).

  • GET /health and GET /api/stats report, under engine, the engines of
    the components the proxy built: firewall (rust or python),
    loop_breaker (null when no enabled surface needs one) and pii
    (python, rust, presidio or the name of a plugin engine), next to
    the fields of 0.12 (selection, active, pii_active,
    rust_available, rust_version, engine), which are unchanged. With an
    admina.yaml that makes the firewall Python under ADMINA_ENGINE=auto,
    engine.firewall is python while engine.active is rust.
    engine_status() takes the built objects (firewall=, loop_breaker=,
    pii_engine=); without them the three fields are null.

  • admina.yaml is checked against its schema (schema_version: 1,
    admina.core.config_schema). check_config(path=None, *, strict=False)
    and config_path() of admina.core.config return the file checked and
    the paths of its unknown keys (domains.agent_security.firewal,
    alert_channels[0].uri). At startup the proxy logs them as a warning,
    admina.yaml <path>: unknown keys, not read: <paths> (...).

  • At startup the proxy logs, as a warning, the ADMINA_* variables of its
    environment and .env file that nothing reads (ADMINA_* variables not read by Admina: <names> (...); values are never logged). Known
    are the proxy settings, the variables of the engines, the SDK, the
    builtin plugins and the other containers of the stack, and
    ADMINA_GATEWAY_UPSTREAM_<NAME>_API_KEY[_FILE]. Not reported:
    ADMINA_<NAME>_... for each entry point <name> of admina.plugins,
    admina.pii_engines and admina.pattern_packs (upper case, other
    characters than letters and digits as _), and the prefixes of
    ADMINA_ENV_ALLOW_PREFIXES (comma-separated).

  • ADMINA_CONFIG_STRICT (default false): true makes unknown admina.yaml
    keys (ConfigSchemaError) and unknown ADMINA_* variables
    (UnknownVariablesError, a ValueError) stop the proxy at startup.

  • admina.engines.PYTHON_ONLY_FIREWALL_KEYS (custom_patterns,
    disabled_categories, disabled_patterns, pattern_packs) and
    admina.engines.EngineSelectionError (a ValueError).

  • The loop breaker and PII bridges name their engine (engine attribute),
    as the firewall bridges do.

  • compute_oisg_score_from_evidence(evidence)
    (admina.domains.compliance.oisg_evidence, also exported by
    admina.domains.compliance): the OISG adequacy score of the 20 criteria
    of oisg.CRITERIA (same ids and labels) from evidence that the caller
    supplies, {"schema_version": 1, "criteria": {"o1": {"status", "reason", "evidence_ref"}, ...}} with an entry for every criterion (JSON Schema
    admina/domains/compliance/schemas/oisg-evidence.schema.json, read by
    evidence_schema(); dataclasses OISGEvidence and CriterionEvidence).
    A status is satisfied, partial, accepted_gap (a known gap,
    accepted with a reason, which is required and not blank) or
    not_applicable. Scoring: satisfied is worth 5 points, partial 2.5,
    accepted_gap 0; not_applicable criteria are left out and each
    pillar is rescaled to 25 over the criteria that apply; a pillar without
    any has no score (null) and the total is rescaled to 100 over the other
    pillars. Scores are rounded half up to one decimal, and the level is
    get_level() of the total. A missing or unknown criterion, an unknown
    status or key, an accepted_gap without a reason, and evidence where
    every criterion is not_applicable raise OISGEvidenceError (a
    ValueError; problems names each key). The result,
    OISGEvidenceResult (an OISGResult), carries the status, reason,
    evidence_ref and points of each criterion and the number of
    applicable criteria of each pillar, and exports as JSON (to_json(),
    read back by from_dict()) and Markdown (to_markdown(), a table per
    pillar). compute_oisg_score() is unchanged.

  • admina redteam: the detection-efficacy scorecard as a command of the
    package, with the options of scripts/redteam.py (--engine, --corpus,
    --format, --out) and --corpora-dir DIR, --config FILE (default
    $ADMINA_CONFIG), --baseline FILE, --gate and --write-baseline [FILE] (default baseline.json next to --out). With --baseline or
    --gate the run is compared with the baseline (with --gate alone, the
    packaged one) and the result is printed on standard error. Exit status: 0;
    1 when --gate finds a regression (a lower recall or a new false
    positive, or a corpus that ran only on engines the baseline does not
    declare); 2 when an option, a corpus, the configuration or the baseline is
    not valid, or when --engine rust cannot run a selected corpus
    (admina-core is not installed, or --config sets a key that only the
    Python firewall applies). scripts/redteam.py runs this command; there
    --baseline without a file writes the baseline, as --write-baseline
    does.

  • run_suite() of admina.redteam takes corpora_dir, baseline and
    config; without them the scorecard is unchanged.

    • corpora_dir: a directory of external corpora, <name>.jsonl files
      whose rows have the format of the packaged corpus of their detector
      (rows with messages: loop breaker; with expected_types: PII;
      otherwise the firewall, label attack or benign), each listed in the
      directory's SHA256SUMS, which is verified before the run
      (load_external_corpora()). They run after the packaged corpora, under
      their names (the name of a packaged corpus is refused), and corpora=
      selects them by name; an unknown name in corpora= raises ValueError.
      The scorecard's external_corpora holds dir and the detector of each.
    • baseline: a baseline file (or its mapping) compared with the run by
      compare(), limited to the selected corpora and engines; a corpus that
      ran without the Python engine is also a failure when the baseline
      declares none of the engines it ran on. The scorecard's gate holds
      baseline, failures and notes. BASELINE_PATH is the packaged
      baseline.
    • engines: a selected corpus that none of the selected engines can run
      raises ValueError naming the corpora and the reason (with ["rust"]:
      admina-core is not installed, or config sets a key of
      PYTHON_ONLY_FIREWALL_KEYS).
    • config: an admina.yaml whose agent_security.firewall settings
      (custom patterns, pattern packs and their directories, disabled
      categories and patterns, heuristic threshold, allowed tags) build the
      Python injection firewall as the proxy does; the Rust engine runs on the
      injection corpora only when the file sets none of
      PYTHON_ONLY_FIREWALL_KEYS. The PII and loop detectors keep their
      defaults. The scorecard's config holds path and python_only_keys.
      The Markdown scorecard names the external corpora and the configuration.
      InjectionAdapter(config) and all_detectors(firewall_config) take the
      FirewallConfig, and the adapter builds the firewall of each engine once.
  • ruleset_document() in admina.domains.agent_security.ruleset: the
    canonical JSON text whose SHA-256 is ruleset_sha256(), to compare two
    rulesets member by member. GET /v1/admina/ruleset returns it as
    ruleset_document, with ruleset_format.

  • EU AI Act risk classification in Italian, French and German.
    classify_risk() also matches the phrases of
    admina.domains.compliance.ai_act_terms (Art. 5 practices, the areas of
    Annex III, the cases of Art. 50) on whole words of a normalised text, so
    a description of a CV-screening system in Italian is high rather than
    minimal. EUAIActCompliance(term_languages=[...], extra_terms={...})
    narrows the languages and adds terms of the caller. The result adds
    matched_terms and matched_areas. The English keyword lists and their
    results are unchanged; a non-English description can now get a higher
    class than before.

  • An upgrade guide from 0.12 to 0.13: docs/guides/upgrade-0.13.md.

  • admina forensic acknowledge-rebuild and
    ForensicBlackBox.acknowledge_rebuild(): verify the whole chain with the
    key and clear the rebuilt status of a chain whose state was rebuilt.

  • admina.sdk.active_ruleset_sha256(): the ruleset hash of the firewall the
    SDK builds from admina.yaml, on the engine get_firewall() selects. For
    the same file and ADMINA_ENGINE it is the value the proxy reports in
    X-Admina-Ruleset.

Changed

  • The gateway runs the governance pipeline (firewall, PII redaction, egress
    analysis, governance guards) and the PII redaction of completions in
    worker threads instead of the event loop. Governance guards run there
    too: one guard instance can be called by several threads at once, each
    call on the event loop of its thread, so a guard must be thread-safe and
    must not keep loop-bound objects across calls (see BaseGovernanceGuard).

  • A gateway request whose governance pipeline raises is blocked in every
    governance mode and recorded as checks.pipeline ({"action": "ERROR", "error": "<exception class>"}; 0.12 answered 500). Guard contract errors
    still follow ADMINA_GUARD_FAIL_MODE.

  • A completion whose PII redaction runs over the time budget or raises is
    not sent: a non-streaming completion is replaced by the block message, and
    a stream ends with one data: {"error": ...} event (code
    response_redaction_failed) without data: [DONE].

  • GuardrailsAIGuard runs one validation at a time.

  • run_pipeline() takes the texts the firewall scans (scan_texts); by
    default it scans every string of the body, as before.

  • A malformed entry of agent_security.firewall.custom_patterns skips only
    that entry.

  • Streamed chat completions pass through unchanged. With
    ADMINA_GATEWAY_STREAM_MODE=passthrough (the default) and PII redaction
    off, the gateway forwards the upstream SSE bytes as they are, every field
    included, each event as soon as it is complete (0.12 re-emitted
    choices[0].delta.content only). With PII redaction on, or in
    governed mode, each chunk is parsed and re-serialised.

  • The governed stream path sends one chunk for each upstream chunk, with
    all of its fields: ids, choice indexes, roles, tool calls, finish
    reasons and the final usage chunk. With PII redaction on, every string
    of a choice is redacted, per choice and per field across chunks:
    content (a string or a list of parts), reasoning text, tool and
    function call arguments and any other field. The values of index,
    id, type, role, name and finish_reason and the chunk identity
    are kept; logprobs and token_ids are sent as null; other strings
    outside the choices and SSE comment lines are redacted as whole values;
    values nested more than 16 levels deep are dropped. data: [DONE] is
    sent when the upstream sends it.

  • Upstream errors (4xx, 5xx) reach the client of the gateway with their
    status, body and content type, streaming or not. A non-streaming body is
    forwarded unchanged unless PII redaction is on; with redaction on, a
    successful response that is not a JSON object gets 502 (code
    upstream_invalid_response), and in a JSON response every string is
    redacted as a whole value under the same rules as the governed stream
    (structural values and identity kept, logprobs and token_ids of each
    choice null). GET /v1/models forwards the upstream body unchanged
    when no allow-list is set.

  • redis is imported only for a REDIS_URL with a Redis scheme and
    clickhouse_connect only for a non-empty CLICKHOUSE_HOST; boto3
    stays limited to FORENSIC_BACKEND=s3. With REDIS_URL and
    CLICKHOUSE_HOST empty there is no connection attempt. A backend that is
    configured while its package is missing is logged as a warning and left
    off.

  • The loop breaker is built only when the mcp or integration surface is
    enabled, the coordination detector with its quarantine refresh loop only
    with mcp, and the gateway's pipeline threads only with gateway.
    Without the loop breaker /api/stats reports "loop_breaker": {} and the
    startup banner Loop Breaker: OFF.

  • The container entrypoint accepts ADMINA_API_KEY or ADMINA_API_KEY_FILE
    and prints only whether the key is set, not any of its characters.

  • Validation errors of the proxy settings name the setting without echoing
    the configured values.

  • The release workflows run the CI workflow on the tagged commit and
    publish only when it passes. A PEP 440 pre-release tag (for example
    v1.2.0rc1) makes a GitHub pre-release, and the latest image tags
    move only with a final release.

  • The proxy and dashboard images pin their base images by digest. The
    proxy image build fails when the Rust engine does not build (previously
    the image fell back to the Python engines).

  • uv.lock resolves admina-core from ./core-rust ([tool.uv.sources]),
    so uv sync --extra rust or --all-extras builds the Rust engine of the
    same checkout and needs a Rust toolchain; a sync without the rust extra
    does not. The published package metadata keeps the version range of the
    rust extra. The CI python-tests job, make ci-python and
    make ci-linux test against this engine.

  • scripts/check-versions.py compares versions in their PEP 440 spelling
    (1.2.0-rc.1 in the Cargo files matches 1.2.0rc1) and also checks the
    admina-core entry of uv.lock.

  • ADMINA_ENGINE=rust without admina-core installed is an error: the
    engine factories (get_firewall(), get_loop_breaker(),
    get_pii_engine(), the SDK included) and engine_status() raise
    EngineSelectionError ("ADMINA_ENGINE=rust, but admina-core is not
    installed: install admina-framework[rust], or set ADMINA_ENGINE=python
    (or auto) to run the Python engines") and the proxy does not start (it
    ran the Python engines, with a warning). Migration: install the [rust]
    extra, or set ADMINA_ENGINE=auto (Rust when installed) or python.

  • ADMINA_ENGINE=rust with a non-empty
    agent_security.firewall.custom_patterns, disabled_categories,
    disabled_patterns or pattern_packs in admina.yaml is an error:
    get_firewall() raises EngineSelectionError naming the keys
    ("ADMINA_ENGINE=rust, but admina.yaml sets
    agent_security.firewall.custom_patterns, which only the Python firewall
    applies: remove them, or set ADMINA_ENGINE=python (or auto) to run the
    Python firewall") and the proxy does not start (it ran the Python
    firewall, with a warning). Migration: remove these keys from the file
    used with ADMINA_ENGINE=rust, or set auto (the Python firewall runs
    with them, as before) or python. pattern_pack_dirs,
    strict_pack_timing, heuristic_threshold and allowed_tags do not
    select an engine. Under auto the warning names the keys that are set.

  • A value of the wrong type in admina.yaml is an error naming the key:
    load_config() raises ConfigSchemaError (a ValueError; a file named
    by ADMINA_CONFIG gives a ConfigFileError, as for other invalid files),
    for example "admina.yaml /etc/admina/admina.yaml:
    domains.agent_security.loop_breaker.window_size: must be an integer", and
    the proxy does not start. The engine factories that read admina.yaml
    (get_firewall(), get_pii_engine(), pii_mask_style(),
    get_egress_policy(), the SDK included) raise the same error, whatever
    key it names (get_egress_policy() gave an empty allowlist for a value
    it could not read); admina plugin list exits with it, and
    admina doctor reports it under plugin discovery. The values of
    gateway and presidio are
    checked by their readers, as before; empty values (null) and the
    free-form blocks (plugin_config, integrations,
    agent_security.domains, the entries of custom_patterns) are not
    checked. Migration: fix the value the error names.

  • The startup banner reports the engine selection and the engines that run,
    and the settings that switch the firewall and PII redaction on the
    gateway and /mcp: "Engine selection: ADMINA_ENGINE=auto (admina-core
    )" and "Firewall: ON (rust engine) | PII Redaction: OFF (gateway
    and /mcp) | Loop Breaker: ON (rust engine)" (they were "Engine: RUST
    v" and "Firewall: ON | PII Redaction: ON | Loop Breaker: ON",
    whatever the engines and settings).

  • admina_engine_info has the labels engine and firewall (the
    firewall's engine; engine was rust whenever admina-core was
    installed), loop_breaker (none when none is built), pii,
    pii_redaction (on, off), rust_available, rust_version,
    selection and version.

  • README and MODEL_CARD describe the firewall as a heuristic signal, give
    the pattern counts of each engine (44 builtin patterns on Python, 15 on
    Rust), the engine selection and the official image (the Rust firewall
    and loop breaker under auto, no spaCy model), and state what the
    engine microbenchmark measures; the full Docker Compose stack runs 8
    containers.

  • admina_requests_total has the labels surface (gateway, mcp,
    integration) and action (ALLOW, BLOCK, REDACT, CIRCUIT_BREAK,
    ERROR), with a sample for each enabled surface and action from startup;
    sum(admina_requests_total) counts what the unlabelled counter counted,
    plus the gateway and /api/v1/validate, less the /mcp requests whose
    body is not JSON (answered 400 before they are governed).
    admina_requests_blocked_total, admina_requests_allowed_total,
    admina_requests_redacted_total, admina_avg_latency_ms (now the mean
    duration of the counted requests) and the requests_* counters of
    /api/stats count every governed surface, so the dashboard score counts
    gateway blocks too.

  • An /mcp request is recorded (counted on /metrics, its
    governance.decision event, its ClickHouse row) once it has been
    answered, with the action of its response: a response that a governance
    guard blocks (its inspect_response verdict, or its contract error with
    ADMINA_GUARD_FAIL_MODE=closed) makes the request a BLOCK of domain
    response_guard, with the guard's risk_level (HIGH for a contract
    error). It is counted in admina_requests_total{surface="mcp", action="BLOCK"} and admina_requests_blocked_total only, and sends one
    alert.

  • The ClickHouse request_hash of an /mcp row is the whole SHA-256 (64
    hexadecimal characters), the request_sha256 of its event.

  • Each gateway chat completion writes two forensic records,
    gateway_request and then gateway_response; count gateway_request
    records to count requests.

  • A non-streaming completion blocked by the response scan, or whose PII
    redaction did not finish, is answered as ADMINA_GATEWAY_BLOCK_STATUS
    says, with X-Admina-Action: BLOCK.

  • A chat completion request whose JSON body is not an object is answered
    400 (Invalid JSON body).

  • The filesystem forensic store writes each record, _chain_state.json and
    _chain_state.json.sig atomically and durably: to a temporary file in the
    same directory (.<name>.<random>.tmp, never read as a record), fsynced,
    renamed into place, then the directory fsynced; a new directory is fsynced
    in its parent. Files keep the permissions a new file gets. A record never
    goes into an hour directory earlier than the one of the record before it.

  • A forensic record that cannot be written (filesystem or S3) is not counted:
    the next record takes its sequence number, so the stored chain has no gap.

  • FORENSIC_BACKEND=filesystem without a directory (or with one that
    cannot be created), and FORENSIC_BACKEND=s3 without boto3 or with S3 not
    reachable, no longer fall back to the in-memory store: in open mode the
    proxy starts with a store that records nothing (UnavailableForensicStore,
    forensic_writable: false) and logs an error; in closed mode it does not
    start. A directory that exists but cannot be written is logged at startup
    (and stops the proxy in closed mode).

  • The S3 forensic store reads its chain state and records with the retries
    of its writes (FORENSIC_S3_MAX_RETRIES, FORENSIC_S3_BASE_DELAY_S), and
    an object is missing only when S3 answers that it does not exist
    (NoSuchKey). Any other error still there after the retries is a read
    error, as for a file of the filesystem store: a chain state that cannot
    be read is not used, and a record that cannot be read at startup keeps
    the backend from opening (as above). verify_bucket() raises such an
    error instead of reporting the chain state missing.

  • POST /api/v1/audit answers {"recorded": false, "error": ...} when the
    record could not be written; /mcp sends no X-Admina-Forensic-Hash then.

  • The admina init template leaves the forensic backend to
    FORENSIC_BACKEND and FORENSIC_BASE_DIR (the lines in admina.yaml are
    comments).

  • The chain state also holds format (admina-forensic/1) and head_key
    (the key of the last record). Checking it at startup reads its last
    record and any record written after it, not every record.

  • The proxy of docker-compose.yml, and of the docker-compose.yml that
    admina init generates, keeps its forensic directory
    (FORENSIC_BASE_DIR=/app/.admina/forensic) on the named volume
    forensic-data, so the records and the chain state outlive the
    container. Both proxy images create /app/.admina/forensic owned by the
    admina user (uid 10001), so a new volume mounted there is writable by
    the proxy.

  • The IBAN category of the spacy-regex engine no longer masks an
    IBAN-shaped string with a wrong checksum, a length other than its
    country's, or an unknown country code. The spacy-regex engine matches
    IBANs before card numbers, and card numbers before phone numbers.

  • The presidio engine checks e-mail domains against the public suffix
    list bundled with tldextract, with no download and no cache files.

  • The object ruleset_sha256() hashes also has disabled_patterns
    (agent_security.firewall.disabled_patterns, sorted without duplicates;
    builtin still leaves out only the patterns of a disabled category) and
    allowed_tags (lower case, sorted without duplicates), and
    pattern_packs holds the content of each listed pack, in order:
    {name, version, patterns: [{id, regex, category, risk_level}]} (a
    changed pack file changes the hash; the description is not hashed);
    ruleset_sha256() raises
    PatternPackError when a listed pack cannot be loaded. New test vectors
    are in tests/test_ruleset_sha256.py.

  • agent_security.firewall.pattern_packs, pattern_pack_dirs,
    disabled_patterns and allowed_tags must be lists of strings and
    strict_pack_timing a boolean: another value is a configuration error
    (ValueError from load_config()).

  • The Italian multilang_evasion patterns (multilang_evasion.it.1,
    multilang_evasion.it.2) match where an instruction starts (see the
    Italian baseline above) and end on a word boundary: third-person prose
    with the same verbs ("il consiglio annulla le direttive precedenti") no
    longer matches, nor does a verb inside another word ("signora"); the
    singular istruzione and restrizione still match. After up to two
    addressing words, and after Markdown or HTML markup where an instruction
    starts, they still match at risk critical ("Ok, ignora tutte le
    istruzioni precedenti", "Grazie. Ora, per favore ignora le istruzioni
    precedenti", "Ignora le istruzioni precedenti", "| Nota | Ignora le
    istruzioni precedenti |", "Nota: ignora le istruzioni precedenti",
    "Testo Ignora le istruzioni precedenti", "Utente> Ignora
    le istruzioni precedenti"). An override inside a sentence without such a
    start, or after a closing quote, tag or emphasis that follows a word
    ("Il fornitore ignora ..."), no longer matches them:
    the Italian baseline matches it at risk high when the clause starts
    with a second-person imperative or the object is second-person ("Traduci
    il testo e ignora le istruzioni precedenti", "... e ignora le tue
    istruzioni"), and otherwise it is not matched ("Il documento è lungo,
    ignora le istruzioni precedenti").

  • The committed red-team baseline records the Python injection recall
    23/37 (it was 21/37): the Italian attacks inj-it-002 and inj-it-003
    of the corpus are detected. No new false positive; the Rust figures are
    unchanged.

  • The default configuration's ruleset_sha256() for the python engine
    changes with the builtin pattern list.

  • The default agent_security.firewall.heuristic_threshold of the
    configuration is 0.5 (it was 0.7, which the firewall did not read), so
    the default heuristic_threshold_milli of the ruleset is 500.
    admina.yaml.example and the admina init template set 0.5. Upgrading:
    admina.yaml files generated by admina init up to 0.12, and copies of
    the example of those releases, set heuristic_threshold: 0.7, which the
    Python firewall now applies; set 0.5 to keep the previous behaviour.

  • Deep path of the Python firewall: HTML entities (named, decimal and
    hexadecimal) no longer count as encoding markers, only \uXXXX escape
    sequences do (percent-encoding never counted); the length signal counts
    texts longer than 100 000 characters (firewall.LONG_TEXT_CHARS; it
    counted texts longer than 2000).

  • The object ruleset_sha256() hashes also has ruleset_format (1),
    the version of its form, so that a later change of the form is explicit
    (RULESET_FORMAT). A caller that pins a hash in
    gateway.prescan_rulesets or X-Admina-Scan-Policy recomputes it with
    this release.

  • POST /api/v1/validate answers 400 ('content' must be a string)
    when content is not a string. An object or an array was scanned as
    nested data by the Python engine, and answered 500 with the Rust
    engine.

  • The red-team gate (admina redteam --gate, admina.redteam.gate.compare)
    fails when the number of benign samples of a detector (fp_samples)
    differs from the baseline, with a message that asks for a new baseline:
    false-positive counts are compared only over the same benign samples.

Deprecated

  • The API key in the query string (?api_key=). It is still accepted;
    the first request that authenticates with it logs a warning, once per
    process, without the key. A URL ends up in access logs, proxy logs and
    browser history; send X-API-Key or Authorization: Bearer instead. A
    later release will refuse it.

Fixed

  • The auth middleware runs the request handler once, after the first auth
    provider that returns a user. An exception raised by the handler gets
    the application's 500 response and is not retried with another provider.

  • GovernedModel.ask() and stream() work with the SDK alone
    (pip install admina-framework, without numpy and scikit-learn). They
    built the Python loop breaker on every call, loop detection on or off,
    and failed with ModuleNotFoundError: numpy; the loop breaker is now
    built only when loop detection runs. When it runs without those
    packages, the ImportError names admina-framework[proxy] (or [rust]).

  • The role_hijacking pattern of the Rust firewall (admina-core) matches
    whole words only. It matched "act as" inside longer words, so English
    text such as "impact assessment" or "the AI Act asks" was reported as a
    role-hijacking attempt. Attacks written with whole words ("act as",
    "you are now", "pretend you are", "from now on you") are still matched.

  • The dashboard feed, trend and suggestions work without ClickHouse.
    /api/dashboard/feed, /api/dashboard/trend and
    /api/dashboard/suggestions used to answer empty, with
    "error": "ClickHouse not available", when no ClickHouse was configured,
    as in an embedded deployment. They now read the recent records of the
    forensic black box: one event per governed request (/mcp, the gateway
    and /api/v1/validate), in the same columns as a ClickHouse row, and the
    answer carries "source": "forensic_recent".
    ForensicBlackBox.recent_records() keeps the last 1,000 records written
    by the running proxy, in memory, with every backend; records written
    before a restart are not read back. The WebSocket live feed keeps
    reading the event bus.

  • The dashboard's EU AI Act countdown shows the deadline it counts down
    to. The date next to the countdown was a fixed "August 2, 2026", while
    the number of days came from the enforcement_deadline of
    /api/dashboard/compliance (2 December 2027 for Annex III); both now
    read that field.

  • The dashboard's EU AI Act help shows a command that works. The
    example curl for POST /api/compliance/gap-analysis targeted
    http://localhost:8080; it now targets the origin that served the page,
    and the help states that the route accepts POST only (a GET, such as
    opening the address in the browser, answers 405 Method Not Allowed).

Notes

  • As of 0.12.1, Admina is developed with AI assistance (Claude). Commits
    written with it carry a Co-Authored-By trailer that names the
    assistant, and every change is reviewed and tested by the maintainers.
    See "AI-Assisted Contributions" in CONTRIBUTING.md.
  • The timing tests of tests/test_firewall_pattern_timing.py carry the
    benchmark marker and are excluded from CI (-m "not benchmark"): on
    shared macOS runners their time ratios and budgets vary between runs.
    They run with pytest -m benchmark tests/test_firewall_pattern_timing.py.