Server data from the Official MCP Registry
Governance proxy for MCP servers: allowlist, forced dry run, human confirmation, blast radius, audit
About
Governance proxy for MCP servers: allowlist, forced dry run, human confirmation, blast radius, audit
Security Report
Valid MCP server (1 strong, 1 medium validity signals). No known CVEs in dependencies. Package registry verified. Imported from the Official MCP Registry.
4 files analyzed · 1 issue found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
What You'll Need
Set these up before or after installing:
Environment variable: AIRLOCK_JWT_SECRET
Environment variable: AIRLOCK_SECRET
How to Install
Add this to your MCP configuration file:
{
"mcpServers": {
"io-github-shalimov04-mcp-airlock": {
"env": {
"AIRLOCK_SECRET": "your-airlock-secret-here",
"AIRLOCK_JWT_SECRET": "your-airlock-jwt-secret-here"
},
"args": [
"mcp-airlock"
],
"command": "uvx"
}
}
}Documentation
View on GitHubFrom the project's GitHub README.
mcp-airlock
mcp-airlock is a proxy you put between an AI agent and an MCP server when the server can do
things you don't want an agent doing on its own. It speaks the 2026-07-28 revision of the
protocol (the stateless one: no session, no initialize, one POST per request) and adds
the parts the protocol leaves to you: who is allowed to call what, dry runs by default,
a human in the loop for dangerous calls, an audit trail and tracing.
It is deliberately small. There is no UI, no policy language beyond flat YAML, no MCP SDK of its own. The whole proxy is one Starlette app plus a few helper modules.
How a call goes through
The agent sends a normal tools/call to the proxy instead of the server. The proxy:
- Works out who is calling. That comes from a JWT (
Authorization: Bearer) or, if you run it behind a gateway that already did the authentication, from anX-Airlock-Principalheader. It is never taken from the request body. No principal, no call. A name that is blank or has whitespace around it is no principal either, from either source: trimmed,alicewould bealice. - Looks the tool up in the policy. Tools that are not listed are refused. Listed tools have
a risk tier per environment, so the same
delete_servicecan be free indevand gated inprod. - Depending on the tier:
L0(read) goes straight through.L1(suggest) always goes through withdry_run: true, whatever the agent asked for.L2(confirm) goes through withdry_run: truefirst, and the result comes back to the agent asinput_requiredwith a description of what would happen and a signedrequestState. When a person says yes, the agent repeats the call with that state and the proxy executes it for real, once. Repeating it again is refused.L3(auto) goes through as sent.
- Checks the blast radius: how many objects one call touches (the length of a list argument you name in the policy) and how many a principal has touched in the last hour or day.
- Forwards the call, cuts the response down to the output cap if it is too big, and marks anything in it that smells like a prompt injection. Marking only; it does not change what the agent gets to see.
- Writes two audit records, one before the upstream call and one after, whatever happened. The JSONL file gets each one before the proxy goes on; the Postgres table, when there is one, is written from a queue a moment later.
Refusals come back as tool results with isError: true, not as protocol errors, so the
model sees why and can do something else. Every tools/call result, a pending one included,
carries the verdict and the rule that produced it in _meta. The io.mcp-airlock/ keys there
are the proxy's alone: any the upstream puts into a result, one of its content blocks, a
tools/list answer or a tool in it are removed before the proxy adds its own, and any the
client sends are removed before the call is forwarded.
The proxy accepts three methods: tools/call as above, tools/list and server/discover.
A tools/list answer is cut down to the tools the policy lists for the caller (the number of
hidden tools is in _meta["io.mcp-airlock/hidden_tools"]), then checked against the
pins and scanned for injection phrases. Any other method is
refused with METHOD_NOT_FOUND.
Installing
From PyPI, as a tool or a package:
uvx mcp-airlock --help
pip install mcp-airlock
Two features are extras, so the base install stays small:
| Extra | Brings | Needed for |
|---|---|---|
postgres | psycopg, psycopg-pool | AIRLOCK_STORE_DSN, AIRLOCK_AUDIT_DSN, airlock-audit query --dsn |
otlp | the OTLP HTTP span exporter | OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
uvx --from 'mcp-airlock[postgres,otlp]' mcp-airlock ...
pip install 'mcp-airlock[postgres,otlp]'
A DSN without the postgres extra stops the proxy at startup with that hint. An OTLP
endpoint without the otlp extra is a startup warning (an error under --strict) and the
proxy runs without exporting.
The container image ghcr.io/shalimov04/mcp-airlock includes both extras. It listens on
0.0.0.0:9000, runs as a non-root user and has /data as its working directory, so
audit.jsonl lands there. The user is uid 65532, so a host directory bind-mounted at /data
has to be writable by that uid (chown 65532 ./data), or the start stops with a Permission denied message for the audit file. Each release is tagged with its version and its major.minor
(X.Y.Z and X.Y).
From a checkout, uv sync --all-extras installs everything including the test tools.
Running it
The released version, no clone needed:
uvx mcp-airlock --policy policy.yaml --upstream http://127.0.0.1:8080/mcp --env prod
The same as a container:
docker run --rm -p 9000:9000 -v $PWD/policy.yaml:/data/policy.yaml \
--add-host=host.docker.internal:host-gateway \
ghcr.io/shalimov04/mcp-airlock:0.3 --policy policy.yaml --upstream http://host.docker.internal:8080/mcp --env prod
--add-host gives Docker Engine on Linux the host.docker.internal name that Docker Desktop
provides by itself. The upstream has to listen on an address the container can reach, not only
on 127.0.0.1, and a host firewall may need to allow traffic from docker0; --network host
with --upstream http://127.0.0.1:8080/mcp sidesteps both.
The image has a HEALTHCHECK that asks http://127.0.0.1:9000/healthz with python every 30
seconds (the image has no curl), ignoring any HTTP_PROXY. If you pass --port, or a --host
bound to a specific non-loopback address, override it with --health-cmd or a compose
healthcheck:. Its --start-interval needs Docker Engine 25+ (older ones ignore it).
Kubernetes ignores the check: point a liveness probe at /healthz and a readiness probe at
/readyz, as the Helm chart does.
From a checkout:
uv sync --all-extras
uv run pytest
uv run python demo.py
The demo starts a fake upstream with a handful of tools on port 9001 and the proxy on 9000,
walks through the interesting cases (refused tool, forced dry run, confirmation, replay,
blast radius, output cap, injection marking) and leaves the audit log and spans in
examples/.
The short version, recorded against that same fake upstream. An agent tries to delete a production service, gets a dry run and a confirmation prompt instead, the confirmation works exactly once, and a poisoned read result comes back flagged:

docs/make_demo_gif.py re-records it (uv run --with pillow python docs/make_demo_gif.py).
docs/clients.md shows how to point Claude Code and Cursor at the proxy, what the agent sees
when a call is refused or held for confirmation, and which clients have been tested with the
confirmation prompt.
Against a real server:
uv run mcp-airlock --policy policy.example.yaml --env prod \
--upstream http://127.0.0.1:9001/mcp --audit audit.jsonl
There are ready-made policies for the GitHub, Grafana, Kubernetes and Postgres MCP servers in
examples/policies/. They were written against the servers' source at a pinned commit, so
check them against your actual server before trusting them:
uv run airlock-policy lint examples/policies/github.yaml
uv run airlock-policy diff examples/policies/github.yaml --upstream http://127.0.0.1:8080/mcp --env prod
lint needs no network. It reports a tool without tiers (no_tiers, an error), a write tool
without a description (no_description), a count_arg that relies on the global blast radius
(blast_radius_default), a where rule for an environment no tier mentions (where_env_unknown),
a where regex with two unbounded repeats in a row (where_regex_cost) and an environment no tool
covers (env_unused); --env adds environments that must be covered. A policy without
environment lints with the first --env (or AIRLOCK_ENV) as its environment, the way the proxy
would run it; without either it is an error. diff and pin take --env, then AIRLOCK_ENV, then
the policy's own environment. A policy that cannot be read or does not validate is ERROR invalid
in all three commands, and an upstream that does not answer tools/list like an MCP server is
ERROR upstream. diff asks the server itself (not the proxy, which hides unlisted tools) for
tools/list and tells you which allowlisted tools the server no longer has (missing_upstream, an
error), which L1/L2 tools have no dry_run argument (no_dry_run), which where rules name an
argument that is not in the schema (where_unknown_arg) and which server tools the policy does not
mention (not_allowlisted). With --pins it also checks the pins.
Both exit 1 when any finding is an error.
In Kubernetes
There is a small Helm chart in charts/mcp-airlock/ (Deployment, Service, a ConfigMap for the
policy, a Secret for the keys, probes on /healthz and /readyz, a hardened security
context). There is no chart repository, so install it from a checkout:
helm install airlock charts/mcp-airlock \
--set upstream=http://my-mcp-server:8080/mcp --set environment=prod \
--set-file policy=policy.yaml
The values are documented in charts/mcp-airlock/values.yaml. The points that matter:
- Identity. By default the chart creates a Secret with a random
AIRLOCK_JWT_SECRETand keeps it across upgrades. Read it withkubectl get secret airlock-mcp-airlock -o jsonpath='{.data.AIRLOCK_JWT_SECRET}' | base64 -dand sign HS256 tokens withsubandexp. Anyone who can read Secrets in the namespace (or the Helm release Secrets) can mint any principal. For JWKS setenv.AIRLOCK_JWKS_URLandenv.AIRLOCK_JWT_AUDIENCE, and setgenerateJwtSecret=falseso no unused secret is generated. For a trusted gateway header setgenerateJwtSecret=falseandenv.AIRLOCK_TRUST_PRINCIPAL_HEADER=1, and make sure only the gateway can reach the Service: the chart ships no NetworkPolicy. - Credentials go in
existingSecret, never inenv. That coversAIRLOCK_UPSTREAM_AUTH,AIRLOCK_APPROVAL_WEBHOOK,AIRLOCK_AUDIT_DSN,OTEL_EXPORTER_OTLP_HEADERSand the JWT secret; the chart refuses them inenv. A credential that lives in another Secret or a ConfigMap goes inextraEnvas a plain Kubernetes env entry with avalueFrom(or inenvFrom). AnextraEnvname may replace one of the optionalexistingSecretkeys; a name that is also inenv, orAIRLOCK_STORE_DSNandAIRLOCK_SECRETwithsharedStore, is refused, since one container cannot set a name twice. Do not put credentials in theupstreamURL either, they end up in the pod spec. Whole numbers in a values file, such as the byte limits inenvorextraArgs, may be left unquoted; a plainvalueinextraEnvmust be a string. - More than one replica. Create a Secret with
AIRLOCK_STORE_DSNandAIRLOCK_SECRET, then--set existingSecret=airlock --set sharedStore=true --set replicaCount=3. The chart refuses more replicas withoutsharedStore.AIRLOCK_SECRETis only set together with the shared store: a fixed key with per-process memory would let a used confirmation run again after a restart. See Postgres for the pool and the timeouts. - Audit.
/datais an emptyDir, soaudit.jsonlgoes with the pod. SetAIRLOCK_AUDIT_DSN, or pointdataVolumeat apersistentVolumeClaim(and setdataVolume.emptyDir=null, Helm merges maps), to keep it. A persistentdataVolumeswitches the Deployment toRecreate(two pods on one file would fork the audit hash chain, andairlock-audit verifywould reportprev mismatch) and the chart refuses it withreplicaCountabove 1. With several replicas use an emptyDir andAIRLOCK_AUDIT_DSN; the hash chain is per pod, so theairlock_audittable holds one interleaved chain per pod (with gaps where the sink dropped records). In that setup the table is the durable copy: a record the sink drops while the audit database stalls, or one still queued at shutdown or when the pod crashes, exists only in the pod's emptyDir and goes with it (see Postgres). Rotation flags go inextraArgs. - Policy changes. A new policy rolls the pods; SIGHUP reload is not used here. A pod that
is told to stop keeps serving for
preStopSeconds(5) first, so kube-proxy has dropped it from the Service before the listener closes and a rolling update refuses no connections; the nativesleephook needs Kubernetes 1.30+, so on an older cluster setpreStopSeconds=0(the chart refuses the sleep there, the pod spec would be rejected otherwise).terminationGracePeriodSeconds(30) covers that sleep, the open calls and the OTLP flush. - Strictness.
strictis on, so any startup warning stops the pod. The log says why. - Approve page.
/approveis on the same Service, so put it behind SSO, as the Confirmations section says. - Tracing. Set
env.OTEL_EXPORTER_OTLP_ENDPOINTto export spans; the image has the extra.OTEL_EXPORTER_OTLP_HEADERSis a credential: put it inexistingSecret, or inextraEnvwith asecretKeyRefto a Secret of your own. - GitOps. Argo CD and other
helm templatebased tools do not runlookup, so the generated key would change on every render. UseexistingSecretthere.
Configuration
Flags
mcp-airlock takes these flags. Only --policy and --upstream are required.
| Flag | What it does |
|---|---|
--policy PATH | The policy file. Required. |
--upstream URL | The MCP endpoint of the server behind the proxy. Required. |
--env NAME | Environment name, picks the tier column. Overrides AIRLOCK_ENV and the policy's own environment. |
--audit PATH | The JSONL audit file. Default audit.jsonl in the working directory. |
--audit-max-bytes N | Rotate the audit file before a write would take it past N bytes. 0 or unset: never. |
--audit-keep N | Rotated audit files to keep. Default 5, at least 1. |
--otel-file PATH | Append spans to this file as JSON Lines, one span per line. The same as AIRLOCK_OTEL_FILE. |
--pins PATH | Tool pins file written by airlock-policy pin. The same as AIRLOCK_PINS. |
--strict | Exit with status 2 if the configuration has any startup warning. |
--host ADDR | Listen address. Default 127.0.0.1; the container image passes 0.0.0.0. |
--port N | Listen port. Default 9000. |
Environment variables
Everything else is environment variables. None are required for a single-process setup.
| Variable | What it does |
|---|---|
AIRLOCK_ENV | Environment name, picks the tier column in the policy. Overrides the policy's environment; --env overrides both. |
AIRLOCK_JWT_SECRET | Verify bearer tokens with HS256. sub becomes the principal, groups the groups. exp and sub are required claims. |
AIRLOCK_JWKS_URL, AIRLOCK_JWT_ISSUER, AIRLOCK_JWT_AUDIENCE | Verify bearer tokens against an OIDC provider (RS256/ES256). Takes precedence over the shared secret. Set the audience; without it any token from that provider is accepted. The issuer is checked only when set. |
AIRLOCK_GROUPS_CLAIM | Claim to read groups from. Default groups. A list, or a string split on commas and spaces. |
AIRLOCK_TRUST_PRINCIPAL_HEADER | Set to 1 to accept X-Airlock-Principal and X-Airlock-Groups (comma or space separated). Off by default. Only turn it on behind a gateway that sets those headers itself and strips them from clients. A bearer token that fails verification never falls back to the header. |
AIRLOCK_SECRET | Key for signing confirmation tokens. Random per process if unset, which means a restart forgets pending confirmations. Set it only together with AIRLOCK_STORE_DSN: replicas need the same key, but with the memory store a fixed key would let a used confirmation run again on another replica or after a restart. |
AIRLOCK_STORE_DSN | Postgres DSN for the shared state: used confirmation keys, approvals, the prompt text shown on the approve page, blast-radius counters. Without it the state lives in process memory. Needs the postgres extra. |
AIRLOCK_AUDIT_DSN | Postgres DSN for the audit log, in addition to the JSONL file. Needs the postgres extra. |
AIRLOCK_STORE_CONNECT_TIMEOUT | Connect timeout in seconds for the Postgres store and the audit sink, unless the DSN or PGCONNECT_TIMEOUT sets one. The connect timeout in force (at least 2 s) is also the longest a store call waits for a pooled connection or for a reply, and, with one second added, the longest an audit write waits for the database. Default 10. An integer from 1 to 86400. See Postgres. |
AIRLOCK_STORE_POOL_SIZE | Most connections the Postgres store keeps open per replica. Default 4. A positive integer. See Postgres. |
AIRLOCK_APPROVAL_WEBHOOK | Slack-style incoming webhook, or a Telegram https://api.telegram.org/bot<token>/sendMessage URL. Confirmation prompts are posted there with an approve link. |
AIRLOCK_APPROVAL_MODE | oob or inband. With oob only the approve link approves; an accept in inputResponses is treated like no answer. With inband the client's accept approves; an accept on an oob token is ignored there too. Default oob when a webhook is set, inband otherwise. oob without a webhook is refused at startup. |
AIRLOCK_TELEGRAM_CHAT | Chat id for the Telegram case. A Telegram URL without it is a startup warning: no message can be sent. |
AIRLOCK_PUBLIC_URL | Base URL for approve links. Default http://127.0.0.1:9000. |
AIRLOCK_PINS | Path of the tool pins file, the same as --pins. Without it no tool is pinned. See Pinning tool descriptions. |
AIRLOCK_OTEL_FILE | Path of the span file, the same as --otel-file. |
AIRLOCK_DEBUG | Set to 1 to get the Python traceback for a startup error instead of the one-line mcp-airlock: <message>. |
AIRLOCK_UPSTREAM_AUTH | Value of the Authorization header sent to the upstream. This is the proxy's own credential; the caller's identity travels in _meta instead. |
AIRLOCK_MAX_REQUEST_BYTES | Largest request body accepted, in bytes. Default 1048576 (1 MiB). A bigger body is refused with HTTP 413. A positive integer. |
AIRLOCK_MAX_UPSTREAM_BYTES | Largest upstream response read, in bytes. Default 8388608 (8 MiB). The proxy stops reading at the limit and drops the response. It asks the upstream for an uncompressed answer and refuses a compressed one: for a tool call both are a tool error (upstream.too_large, upstream.encoded), for tools/list and server/discover an HTTP 502. A positive integer. |
PGCONNECT_TIMEOUT | libpq's own connect timeout. When set, the proxy adds no connect_timeout of its own to the DSNs. |
OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | Turn on OTLP span export over HTTP/protobuf (needs the otlp extra). The base URL gets /v1/traces appended; the traces URL is used as it is. Only http/protobuf is supported and OTEL_EXPORTER_OTLP_PROTOCOL is not read: point it at the collector's HTTP port (4318), not gRPC (4317). |
OTEL_EXPORTER_OTLP_HEADERS | Headers for the export request, for example authorization=Bearer <token>. Treat it as a secret. |
OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES | Resource of the spans. service.name defaults to mcp-airlock. |
The OpenTelemetry SDK reads the other standard variables too: the rest of
OTEL_EXPORTER_OTLP_* (timeout, compression, certificate) and OTEL_BSP_* (batching). See
Tracing.
A bad value (a limit that is not a positive integer, a DSN that does not parse, an unknown
approval mode, a policy file that is missing, is not YAML or fails validation, an audit or span
file that cannot be opened, an invalid pins file, an OTEL_* setting the SDK rejects) stops the
start with a mcp-airlock: <message> line on stderr and exit status 1, with no traceback;
AIRLOCK_DEBUG=1 prints the traceback instead. A flag with a bad value (--port 99999) is
refused by the argument parser with exit status 2.
Startup warnings
At startup the proxy prints a warning to stderr for each of these:
- no identity is configured (no JWT secret, no JWKS URL, no trusted header): every call gets 401
AIRLOCK_JWKS_URLwithoutAIRLOCK_JWT_AUDIENCEAIRLOCK_JWT_SECRETshorter than 32 bytesAIRLOCK_TRUST_PRINCIPAL_HEADER=1together with JWT settings: a request withoutAuthorizationis trusted on the header aloneAIRLOCK_STORE_DSNwithoutAIRLOCK_SECRET: replicas sign with different keysAIRLOCK_SECRETwithoutAIRLOCK_STORE_DSN: used confirmations are remembered only in this process, so a confirmed call can run again on another replica or after a restartAIRLOCK_APPROVAL_WEBHOOKwhileAIRLOCK_PUBLIC_URLis the default: nobody else can open the approve linkAIRLOCK_APPROVAL_WEBHOOKthat is not anhttp(s)URL with a host, or a Telegram URL withoutAIRLOCK_TELEGRAM_CHAT: no message can be delivered, and inoobmode nothing can be approvedAIRLOCK_TELEGRAM_CHATwithoutAIRLOCK_APPROVAL_WEBHOOK: it is ignored- an OTLP endpoint without the
otlpextra: spans are not exported
With --strict any warning stops the start with exit code 2.
Endpoints
| Path | What it does |
|---|---|
POST /mcp | The MCP endpoint the client talks to. |
GET /healthz | {"status":"ok"} while the process is up. |
GET /readyz | 200 {"status":"ok"} when the store responds and its tables exist (missing ones are created again by the probe), 503 {"status":"unavailable"} on an error or after 2 seconds. With the memory store it is always 200. |
GET /approve/<token> | The approve page for an out-of-band confirmation. Renders only. |
POST /approve/<token> | Records the approval. |
The probes need no credentials, write no audit record and never call the upstream; /readyz
checks the store only. Use /healthz for liveness: a store outage must not restart the pods.
The policy file
version: 1
environment: prod
output: { max_chars: 16000, chars_per_token: 4 }
blast_radius: { max_per_call: 50, max_per_principal: 500, window_s: 3600 }
tools:
get_service:
tiers: { dev: L0, staging: L0, prod: L0 }
output: { max_chars: 5000 }
set_replicas:
description: scale services up/down (reversible)
tiers: { dev: L3, staging: L1, prod: L2 }
principals:
"group:oncall": { prod: L3 } # on-call people skip the confirmation in prod
count_arg: names # objects per call = len(arguments.names)
blast_radius: { max_per_call: 3, max_per_principal: 5, window_s: 3600 }
delete_service:
description: permanently delete a service (irreversible)
tiers: { dev: L2, prod: L2 } # nothing for staging, so it is refused there
A tier is resolved in this order: an entry for the exact principal, then the first matching
group in the order the token lists them, then tiers[environment]. Keys starting with group:
are groups only: a caller whose own name starts with group: is matched by the groups it is in,
never by its name. The description is what the person approving the call gets to read, so
write it for them. output and blast_radius on a tool replace the top-level values for that
tool; window_s is at most 86400 (a day), as far back as the store keeps usage. Unknown keys
are a load error.
where limits a tool by argument values. Every rule that applies must hold, otherwise the call is
denied with args.violation. The check runs right after the allowlist, before the tier and the
blast radius, on dry runs and on the confirmed call too, so nobody is asked to approve a call the
policy forbids.
tools:
pods_delete:
tiers: { prod: L2 }
where:
- { arg: namespace, in: [staging, dev] }
- { arg: name, regex: "tmp-.*" }
- { arg: namespace, not_in: [kube-system], env: [staging, prod] }
Each rule has exactly one matcher: equals, in, not_in or regex (full match, strings only).
env limits a rule to those environments, the default is all. An absent argument fails the rule
unless it has optional: true. A list value must match for every element. A string argument that
parses as JSON null, a list or an object is denied by any rule, because the upstream may decode
it before it validates it. For the same reason not_in also denies a value whose type is not among
the listed values (3 against [kube-system]).
A regex runs in the request path, on the event loop, so its cost is the time one request can stall
the whole proxy. A pattern that can take exponential time on a crafted value is refused when the
policy loads (and by lint): a repetition inside a repetition ((a+)+, (\w+\s?)+), an
alternation inside a repetition whose alternatives can start alike ((a|aa)+) and a
backreference. A fixed outer count does not make these safe: (.*a){12} has about 1024^12 ways
to split a value and is refused too. A nested repetition is fine when every iteration starts or
ends with a character the rest of the group cannot consume ((\.[a-z]{1,63})*,
(\d{1,3}\.){3}) or the group has a fixed width (((ab){2})+), and so is an alternation whose
alternatives start apart ((foo|bar)+). The remaining patterns are polynomial, of a degree set
by the unbounded repeats that run one after another and can take each other's characters
(.*-.*-prod has two: .* can take a dash). A regex is tried only on strings up to 1024
characters, and a longer value fails the rule with a message saying so. Measured with the stdlib
re on a 1024-character worst-case value on a development machine: two such repeats take a few
milliseconds, three (.*-.*-.*-prod) one to a few seconds, four (.*.*.*.*x) tens of seconds;
each extra repeat multiplies the time by about the value length. So the cap is enough for two, and
three or more are refused. lint warns about two (where_regex_cost); a repeat that must stop at
a character it cannot match ([^/]+/[^/]+/[^/]+) is not counted.
The refusal is a heuristic and errs on the safe side, so some safe patterns are refused too:
(\s*,\s*\w+)*, (\w+\s)*\w+, (?:[a-z]+ ?){1,3} and (.*a){2} (a small fixed count) are all
cheap in practice and still refused. To rewrite one: use a single character class with a length
([\w\s,]{1,200}, [a-z ]{1,60}), write the list so that every item is introduced by a character
the item cannot contain (\s*\w+(,\s*\w+)*), or split the check over several where rules.
Rule ids you will see in _meta and the audit log: allowlist.deny, tier.unassigned,
args.violation, tier.L0.read, tier.L1.dry_run, tier.L2.confirm, tier.L2.confirmed,
tier.L2.dry_run, tier.L3.auto, blast_radius.per_call, blast_radius.per_principal,
dry_run.unsupported, catalog.unavailable, catalog.pin_mismatch, store.unavailable,
principal.missing, protocol.<code>, passthrough (a tools/list or server/discover),
mrtr.pending, mrtr.declined, mrtr.replay, mrtr.expired, mrtr.mismatch,
mrtr.bad_signature, mrtr.approved_oob, mrtr.upstream_input_required, request.too_large,
upstream.too_large, upstream.unreachable, upstream.encoded, upstream.bad_reply,
internal.error.
SIGHUP reloads the policy file and, if one is configured, the pins file, as one pair. A file
that does not load keeps the current policy and pins, and the error is logged. The environment
name, approval mode, secrets, store and upstream are not reloaded. Windows has no SIGHUP;
restart the process there.
Confirmations in detail
The confirmation token (requestState) is an HMAC-signed blob carrying the principal, the
tool, a hash of the arguments, the environment, the upstream URL, a random idempotency key,
an expiry (10 minutes) and the approval mode. Nothing is stored when it is issued. When it
comes back the proxy checks the signature, checks that all of those still match the call in
front of it, re-runs the policy, burns the key, then charges the blast-radius counter.
Burning is an atomic insert in the store, so two replicas cannot both execute the same
confirmation. A decline burns the key too, and so does an upstream that turns out to be
unreachable right after the yes: the key is burned before the call is sent and is never
un-burned, so the agent has to ask for a new prompt (the blast-radius charge is given back).
A store failure between burning the key and reserving the blast-radius window burns the
confirmation the same way: the call is denied with store.unavailable and the human approves
again.
Before the prompt is issued the proxy asks the upstream for tools/list and looks at the
tool's schema. If the tool declares dry_run, the dry run is forwarded and its output is
included in the prompt. If it doesn't (most servers today), nothing is forwarded and the
person is asked to confirm without a preview. L1 on such a tool is refused, since there
is no safe way to run it. If the upstream cannot be asked at all, the call is refused with
catalog.unavailable rather than guessed at. The tools/list answer is cached for as long as
the upstream's ttlMs says, per principal; with ttlMs: 0 it is fetched on every gated call.
If the tool mirrors dry_run into an Mcp-Param-* header, the proxy rewrites that header
along with the body.
If an approval webhook is configured, the same prompt goes to Slack or Telegram with a
link. The link carries a second token signed with a different key, so the agent, which
only ever sees requestState, cannot forge it. Opening the link shows a page with a
button; the GET does nothing (link previews and prefetchers would otherwise approve
things), the POST records the approval. The agent finds out by repeating the call
with requestState and no inputResponses: it gets input_required back with
status: pending (and verdict: confirm, rule_id: mrtr.pending in _meta) until the button
is pressed, then the call runs. A human takes minutes; the retry loop built into the official
Python SDK client gives up after about two seconds of polling with
InputRequiredRoundsExceededError. Catch it and retry later with the same requestState.
A retry without an answer for a key that was already executed or declined is refused with
mrtr.replay, not left pending. In inband mode without a webhook there is nothing to wait
for, so such a retry gets the question again, with a fresh requestState; it is evaluated like
a new call, dry run included, and costs the same.
In oob mode an in-band accept leaves the call pending; the result's message and the audit
record say the accept was ignored. A decline still burns the key. The mode travels in the token:
an oob token or an oob replica ignores the in-band accept.
A failed webhook post is logged as the exception class and the HTTP status, never the URL,
which holds the Telegram bot token or the Slack secret path. In oob mode a failed post
means nobody can approve that prompt: a new call without requestState issues a new prompt
and posts again. httpx itself logs every request URL at INFO, so if you configure logging,
keep the httpx logger at WARNING.
In the Slack message &, < and > in the arguments and the dry-run preview are escaped, so
an agent cannot plant a <url|label> link with a hidden target or an <!channel> mention in
it. A bare URL in the arguments or the preview still shows as a URL, and the preview keeps its
line breaks, so an agent can still put an Approve: https://... line of its own into the
text. The proxy's link is the Approve: line at the end of the message; read that one. It is
always there and always last: the text before it is cut at 3500 characters with a note, so the
message fits Telegram's 4096-character limit and Slack never truncates the proxy's line away
behind a planted one. The agent cannot approve its own call either way.
The approve page is a capability URL. Anyone holding it can press the button. Put
/approve behind your SSO proxy or VPN; whatever identity that proxy passes in
X-Airlock-Principal or X-Forwarded-User is recorded next to the approval as
approved_by_source: header. It is verified only when the identity came out of a bearer
token the proxy itself checked (a JWT secret or JWKS URL is configured); any other
Authorization header, Basic included, does not change that. The page shows the prompt
text (the redacted arguments and up to 2000 characters of the dry-run preview), cut at 8000
characters with a note, kept in the store until the prompt expires, and the state of the
request: once it has been executed or declined there is no button, and a POST answers 409
without recording anything; a later POST on an approved request changes nothing and is not
recorded again (two clicks that reach a Postgres store at the same moment can both be recorded;
the approval is still used once). If the store does not answer, the POST answers 503 and
records nothing. The responses are sent with Cache-Control: no-store,
Referrer-Policy: no-referrer, X-Frame-Options: DENY, X-Content-Type-Options: nosniff and a
Content-Security-Policy that forbids framing. The approval itself is audited with
method: approve and rule mrtr.approved_oob.
Audit
Two JSON lines per call, with a shared call_id:
{"ts":"2026-09-14T06:54:08.340+00:00","phase":"intent","call_id":"7ce76db8...","principal":"alice","method":"tools/call","tool":"restart_service","args":{"name":"api"},"verdict":"confirm","rule_id":"tier.L2.confirm","tier":"L2","dry_run":null,"latency_ms":null,"upstream_status":null,"trace_id":"69a54d5a...","detail":null,"prev":"0000...","hash":"a3f1c0de..."}
{"ts":"2026-09-14T06:54:08.340+00:00","phase":"outcome","call_id":"7ce76db8...","principal":"alice","method":"tools/call","tool":"restart_service","args":{"name":"api"},"verdict":"confirm","rule_id":"tier.L2.confirm","tier":"L2","dry_run":null,"latency_ms":0,"upstream_status":null,"trace_id":"69a54d5a...","detail":null,"prev":"a3f1c0de...","hash":"9b27e4d1..."}
The fields, in the order written: ts (UTC, milliseconds), phase (intent before the
upstream call, outcome after), call_id, principal, method, tool, args (redacted),
verdict (allow, deny, confirm or error), rule_id, tier, dry_run (the value
forwarded, null when the argument was left alone), latency_ms, upstream_status,
trace_id, detail, prev and hash.
prev is the hash of the previous record (64 zeros for the first record written into an
empty audit file) and hash is the sha256 of the record's canonical JSON without hash
(keys sorted, no spaces, UTF-8, non-ASCII not escaped). The Postgres sink stores the same two
values in rec.
A crash or a full disk can leave the last line torn: part of a record, with no newline. Such
a fragment can never be chained. The proxy cuts it off rather than keep it: at startup it
appends the fragment to audit.jsonl.torn, cuts audit.jsonl back to the last newline and
logs a warning naming both; a short write it notices itself is cut back at once. The chain
goes on from the last whole record, so verify still passes. A fragment that cannot be cut
(an append-only file, say) stays where it is, and verify reports that line as not JSON.
A last line edited into non-JSON with no newline is moved the same way at the next start, so
keep audit.jsonl.torn: it is the evidence.
Argument values under keys like password, token, api_key, authorization are replaced
with [REDACTED] (whole subtrees included), and so are values that look like bearer tokens,
sk- keys, GitHub or AWS keys and JWTs, also when they sit inside a longer string such as a
note or a command line, or in an argument key. A pattern is only matched at the start of a
word (or right after a literal \n, \r or \t escape in command text), so
disk-cleanup-prod is not an sk- key and is kept as sent. The same redaction applies to the
text shown to approvers, including the dry-run preview. detail holds the output-cap numbers
and the injection rules that fired, when any did; its keys and free text, and the
client-chosen principal, method and tool fields, are scrubbed the same way. Two keys that
scrub to the same text stay apart ([REDACTED], [REDACTED]#2). A secret that matches none
of these patterns (a password in a URL, access_token=...) is stored as sent.
A lone surrogate (JSON allows "\ud800") or a NUL character in client text is stored as
U+FFFD and shown as U+FFFD to approvers; in a response it travels as the JSON escape. A NaN
or an infinity never comes from a client (the request parser refuses them), but one that
reaches the audit is stored as the string "NaN", "Infinity" or "-Infinity", so both
sinks hold the same valid JSON.
To read the log:
uv run airlock-audit query --since 2h --verdict deny
uv run airlock-audit query --principal alice --tool delete_service
uv run airlock-audit query --rule tier.L2.confirmed --phase outcome --limit 20
uv run airlock-audit query --stats
query reads audit.jsonl and its rotated files, oldest first, and prints matching records
as JSONL, newest last. --jsonl names another file. The filters are --principal, --tool,
--verdict, --rule, --phase (intent or outcome) and --since (30m, 2h, 7d or an
ISO 8601 time); --limit keeps the newest N (0: all); --stats prints counts by
verdict and rule instead. The same filters work against Postgres with --dsn or
AIRLOCK_AUDIT_DSN, with the same connect timeout as the proxy. A line that is not a record
(a torn line left by an older version, say) is skipped with airlock-audit: skipped audit.jsonl:57: not JSON (or not a record, for JSON that is not an object) on stderr and
the rest of the log is still printed. A missing file, a DSN that does not parse, a Postgres
that does not answer or a missing postgres extra is one airlock-audit: ... line and exit
code 1, never a traceback. A Postgres error keeps libpq's words (refused, timed out, no such
table), but every value from the DSN other than a number or a setting such as sslmode (the
host, socket path, user, database or password) is blanked to "...", quoted by libpq or not,
so none of them reaches the terminal or a log.
The file grows without bound unless you set --audit-max-bytes N. When a record would take
it past N bytes, audit.jsonl is renamed to audit.jsonl.1, .1 to .2 and so on, and
--audit-keep rotated files are kept (default 5, at least 1; the oldest is deleted). The
hash chain continues into the new file. A record bigger than N is still written. Rotation
is off by default. Lowering --audit-keep deletes the existing .N files above the new
limit at the next rotation.
A request refused before it is attributed to a principal (a malformed or oversized body, no
credentials) is audited without its arguments, and its method and tool are cut at 128
characters, so an unauthenticated client cannot write payloads into the log; the same cut
applies to every protocol denial. Its two records still count toward the rotation budget: anyone
who can reach /mcp can push older files out with enough requests. When the trail matters, keep
the Postgres sink (AIRLOCK_AUDIT_DSN) or ship the files off the host.
To check the chain:
Documentation truncated — see the full README on GitHub.
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Fetch
Freeby Modelcontextprotocol · Developer Tools
Web content fetching and conversion for efficient LLM usage
Worldmonitor
Freeby Koala73 · Developer Tools
Live markets, conflicts, country risk, chokepoints, energy, and China decision signals. 90 tools.
Paperclip
Freeby Paperclipai · Developer Tools
Trending hip-hop artist momentum scores across four cultural dimensions.
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
mcp-creator-python
Freeby mcp-marketplace · Developer Tools
Create, build, and publish Python MCP servers to PyPI — conversationally.
