The admin API is Airlock's control plane for live protection state: clearing a provider quarantine after a verified credit top-up, resetting a tripped model circuit, or clearing a client backoff — without restarting the proxy or flipping a global env flag.
It is off by default. When disabled, every /airlock/admin/* route returns
404 (Airlock does not confirm the routes even exist), and capability headers are
ignored. A config-free deploy behaves exactly as it did before 0.5.0.
The TUI's clear-quarantine keybinding (see TUI Dashboard) is just a loopback client of this same API — the admin API is the foundation, the TUI is one caller.
Add an admin: block to config.yaml:
admin:
enabled: true # off → /airlock/admin/* returns 404, capability hdr ignored
trust_loopback: true # treat loopback connections as the operator (Path A)
allow_insecure_tokens: false # fail-closed guard for token auth over plaintext
behind_tls_proxy: false # assert TLS is terminated by an upstream proxy| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch. false → all admin routes return 404. |
trust_loopback |
true |
A connection from 127.0.0.1/::1 is the operator tier, no credential (Path A). |
allow_insecure_tokens |
false |
Permit token auth on a non-loopback bind without TLS (downgrades the fail-closed startup refusal to a warning). |
behind_tls_proxy |
false |
Assert that TLS is terminated by an upstream reverse proxy, so the fail-closed check is satisfied. |
Bearer tokens — both admin JWTs and guardrail-skip capabilities — are replayable
until they expire if sniffed over plaintext. So at startup, if the admin API
(or capability skips) is enabled and the bind is non-loopback (AIRLOCK_HOST
is not 127.0.0.1/::1/localhost) and native TLS is off (AIRLOCK_SSL_*
unset), Airlock refuses to start.
Resolve it by one of:
- terminating TLS in Airlock itself — set
AIRLOCK_SSL_CERTFILE/AIRLOCK_SSL_KEYFILE(see native TLS in the Operations guide); - asserting an upstream TLS-terminating proxy —
admin.behind_tls_proxy: true; - explicitly accepting the risk —
admin.allow_insecure_tokens: true(logs a loud warning instead of refusing).
Loopback-only deploys are unaffected.
A request is admitted if either path succeeds:
- Path A — loopback is the operator. A connection from
127.0.0.1/::1is treated as the operator tier with no credential, as long astrust_loopbackis on. Reaching loopback means shell access on the host, which already implies proxy privilege. This is the TUI path. - Path B — Bearer token.
Authorization: Bearer <token>, where the token is either the master key (AIRLOCK_MASTER_KEY, full admin + token minting) or a capability JWT carrying the scope the operation requires.
Some operations are loopback-only regardless of token (e.g. manual quarantine).
Reverse-proxy caveat. A reverse proxy forwarding to Airlock on loopback makes every request look local, which would grant Path A to the world. When you expose the proxy, either block
/airlock/admin/*at the edge, or settrust_loopback: falseto force Path B even locally. Airlock warns at startup iftrust_loopbackis on whileAIRLOCK_HOSTis not loopback.
Each operation requires a scope. A capability token carries a list of scopes; the master key (and any loopback operator) satisfies all of them.
| Scope | Grants |
|---|---|
admin:read |
The read-only GET endpoints |
admin:clear_quarantine |
Clear a provider or client→provider quarantine |
admin:reset_circuit |
Reset a model circuit |
admin:clear_backoff |
Clear a client threat backoff |
admin:force_quarantine |
Manually quarantine a provider (loopback-only) |
Tokens are short-lived HS256 JWTs signed with AIRLOCK_JWT_SECRET (which falls
back to an HMAC derivation from AIRLOCK_MASTER_KEY when unset). Set a dedicated
AIRLOCK_JWT_SECRET so token lifetime is decoupled from your LLM master key, and
set AIRLOCK_JWT_SECRET_PREV during a rolling secret rotation so in-flight tokens
verify against the previous secret too.
Mint with the CLI — it signs locally (no network, no server, no DB) using the secret the operator already holds:
# Admin-ops token — --sub is an audit-actor label (authorization comes from the
# signature + scope, not the sub).
airlock admin mint-token --sub lme-ops --scope admin:clear_quarantine --ttl 15m
→ eyJhbGciOiJIUzI1Ni…
# Multiple scopes on one token:
airlock admin mint-token --sub ci-bot \
--scope admin:read --scope admin:clear_quarantine --ttl 1h--ttlaccepts durations like15m,1h; the cap is 24h.- Minting runs as the admin; the token it emits is handed to the client out-of-band (env var, secret manager, CI secret).
Guardrail-skip tokens (
guardrail:skip:*) are minted the same way but their--submust be the client's authenticated key-derived id (key:<last8>). See Guardrails → Per-request guardrail skips.
All routes are under /airlock/admin/ and only exist when admin.enabled: true.
| Method | Path | Scope |
|---|---|---|
GET |
/airlock/admin/providers |
admin:read |
GET |
/airlock/admin/clients |
admin:read |
GET |
/airlock/admin/circuits |
admin:read |
These return a richer view of live protection state than the read-only
GET /health/circuits.
# Loopback operator — no credential needed:
curl http://localhost:4000/airlock/admin/providers
# Remote, with a scoped token:
curl https://airlock.internal/airlock/admin/circuits \
-H "Authorization: Bearer <admin:read-jwt>"Clears the provider-wide quarantine and cascades to every (client, provider)
bucket for that provider — "unblock everyone on openai".
curl -X POST https://airlock.internal/airlock/admin/providers/openai/clear-quarantine \
-H "Authorization: Bearer <admin:clear_quarantine-jwt>" \
-H "Content-Type: application/json" \
-d '{"mode":"probe"}'Scope: admin:clear_quarantine. Body mode:
probe(default) — drop the breaker to half-open: the next request is admitted as a one-shot probe; a success closes the circuit, a failure re-arms it on the policy cooldown. A mistaken clear (credits not actually topped up) self-corrects instead of re-storming.force— blind clear; lifts the quarantine immediately with no probe gate.
Clears exactly one victim bucket — the precise operation for a single client's per-client quarantine, leaving the rest of the provider's clients untouched.
curl -X POST \
https://airlock.internal/airlock/admin/clients/key:b35cf679/providers/openai/clear-quarantine \
-H "Authorization: Bearer <admin:clear_quarantine-jwt>" \
-d '{"mode":"probe"}'Scope: admin:clear_quarantine. Same mode semantics as above.
Closes a tripped per-model circuit breaker.
curl -X POST https://airlock.internal/airlock/admin/models/gpt-5.4/reset-circuit \
-H "Authorization: Bearer <admin:reset_circuit-jwt>"Scope: admin:reset_circuit.
Clears a client's threat-detector backoff.
curl -X POST https://airlock.internal/airlock/admin/clients/key:b35cf679/clear-backoff \
-H "Authorization: Bearer <admin:clear_backoff-jwt>"Scope: admin:clear_backoff.
Manually arms a provider quarantine (break-glass).
curl -X POST http://localhost:4000/airlock/admin/providers/openai/quarantineScope: admin:force_quarantine. Loopback-only — there is no remote/token path
for this operation.
Every successful mutation emits an admin_action record into the same JSONL log
stream as request records (AIRLOCK_LOG_DIR). The record is the audit trail, the
crash-recovery entry, and the channel the TUI's own state replica reads to converge
on the change — the mutation and its audit record are one object.
Each admin_action record carries the actor (the token sub or loopback), the
operation, its target (provider / client / model), the mode, and a timestamp. Filter
the logs for them:
grep '"record_type": "admin_action"' logs/airlock-$(date +%Y-%m-%d).jsonl \
| python -m json.toolRequest records carry
"record_type": "request"(treated as the default when the key is absent, for back-compat with pre-0.5.0 logs).
The admin API and capability tokens are bearer credentials, so they want TLS. You can terminate it in Airlock itself rather than only at a reverse proxy — see native TLS in the Operations guide.