Skip to main content
Agent-Airlock Typing Animation

A deny-by-default type-checker and contract layer for AI agent tool calls — in-process, Pydantic-only

Strict validation, ghost-argument stripping, and self-healing retries — one decorator, any agent or MCP server. Zero core dependencies.1

PyPI version Downloads CI

Python 3.10+ License: MIT GitHub stars PRs Welcome

Test suite: 4,106 tests · Coverage: 87.27% · v0.8.80
Coverage runs 4,094 of 4,106; the 12 excluded are 8 benchmark tests (not correctness tests) and 4 docker-marked (need a daemon).


Get Started in 30 Seconds · Why Airlock? · All Frameworks · Benchmark · Cross-tool comparison · Least-Privilege Benchmark · Docs

⬛ Reproducible block-rate — deterministic, in-process, deny-by-default

Benchmark (one command to reproduce) agent-airlock Compared with
Cross-tool block-rate · 210 tool calls · re-run 2026-08-17 · python -m benchmarks.blockrate 100% blocked · 0% false-positive · p50 ~2µs/decision Meta LlamaFirewall · Invariant Guardrails — model-in-the-loop; scope-claimed, not re-run
Least-privilege / over-privileged tool selection · ToolPrivBench, 100 scenarios, OWASP-Agentic mapped (ASI01–04, 06) · re-run 2026-08-17 · python -m benchmarks.toolprivbench 100% over-priv blocked · 100% low-priv allowed · OPUR 100% → 0% enforced (−100pp)
Guard-suite CVE corpus · MCP Top-10 tagged 100% detection · 0% false-positive
Adaptive-attacker (AgentDojo) · airlock as a defense vs the tool_knowledge attack · all 4 suites · python -m benchmarks.agentdojo.run 86.0% of injection→task target tool-calls blocked · 524/609 (deterministic upper bound on ASR reduction) Model-in-the-loop ASR 45% → 10% — gpt-4o-mini only, one model family (OpenAI), 60-pair subset, wide 95% Wilson CI [5%, 20%] · a single-family point estimate, not a harness-level claim · AgentDojo v1.2.1 (2026-07-31) · last verified 2026-08-08 · benign FP +10pp · RESULTS.md
Native MCP gateway head-to-head · 12 malformed tool-call payloads, identical to both layers · python -m benchmarks.vs_gateway 12/12 blocked · 0% false-positive · p50 ~0.08ms/decision Docker MCP Gateway v2.0.1 — 0/12 blocked, against a build that is now superseded: Hub's moving v2 tag was rebuilt 2026-07-23, after the image this run measured, so a newer gateway exists that has not been tested here and may behave differently. · re-measured live 2026-08-17 (docker mcp v0.42.1 / engine 29.4.3): sent each payload as a real MCP tools/call; the gateway forwarded every one to the backend — see RESULTS.md

BENCHMARK.md · benchmarks/blockrate/RESULTS.md · benchmarks/toolprivbench/RESULTS.md (ToolPrivBench: arXiv:2606.20023) · benchmarks/agentdojo/RESULTS.md (AgentDojo: arXiv:2406.13352) · benchmarks/vs_gateway/RESULTS.md (method) · benchmarks/mcp_conformance/RESULTS.md (MCP spec 2026-07-28, last verified 2026-08-10) · benchmarks/harness_injection/RESULTS.md (method)

Self-curated corpora: a coverage / regression baseline, not an adaptive-attacker (ASR) score. The model-in-the-loop incumbents (LlamaFirewall, Invariant) are cited from their published detection scope and not re-run here — no fabricated competitor number. The native MCP gateway IS re-run: the Docker MCP Gateway v2.0.1 was re-measured live (2026-08-17) on the same 12 malformed payloads and forwarded 0/12 that airlock blocks — that number is reproduced, not claimed; see benchmarks/vs_gateway/RESULTS.md.


⬜ Published nulls — measurements that did not support a claim, published anyway

Same table shape as the wins above, on purpose. A benchmark suite that only publishes the runs that went well is a marketing asset, not a measurement. These are the runs that did not.

Null result (one command to reproduce) What was actually measured Why this is not a claim yet
Matched-pair multi-harness prompt injection · one planted README line, benign twin of identical shape · last verified 2026-08-15 · python -m benchmarks.harness_injection The injection did not work — and neither did its benign twin. claude-code 2.1.233 and codex codex-cli 0.147.0 each acted on the planted script 0/6 on the injected arm and 0/6 on the benign control, while fixing the seeded bug 12/12 and running the test suite 12/12. Total across the matrix: 0/24. n = 6 per harness per arm is too small to license a number. 0/6 carries a 95% Wilson interval of [0.0%, 39.0%] — consistent with a true action rate as high as one in three. A 10% upper bound needs n = 35 (--trials 18, 144 cells), and at n = 6 a true 5% rate would still have produced this exact zero ~74% of the time. Until that run exists this is a measurement, not a finding. · method, power, and the two earlier inconclusive runs

What this does not show: it is not evidence that either harness resists prompt injection — the benign twin of identical shape was ignored just as completely, so what was observed is indifference to this delivery channel, not detection of malicious intent, and it predicts nothing about an injection delivered through a channel these agents do act on.


The AgentDojo gap — the most defensible number here

On AgentDojo's tool_knowledge attack, airlock's least-privilege policy blocks a deterministic 86.0% (524/609) of injection→target tool-calls across all four suites — an upper bound on how much it can reduce attack success. Run model-in-the-loop, the realised reduction is smaller: attack-success-rate 45% → 10% (−35pp), gpt-4o-mini only — one model, one family (OpenAI), a 60-pair subset, 95% Wilson CI [5%, 20%], at a +10pp benign-utility cost. A single model family cannot speak for the defense — ActBench (arXiv:2608.09476) measured ASR spanning 10.1%–94.4% across models under a fixed harness — so this is a scoped point estimate, not a general claim. The bound and the realised reduction disagree by ~51pp because the bound counts every target call airlock can block, while in the loop the model does not always attempt the blocked call and some injections fail upstream of the tool seam. Publishing both, and the gap, is the honest read — most tools in this category report only the larger number. Full numbers: benchmarks/agentdojo/RESULTS.md. That [5%, 20%] interval is wide because n=60 on one model; the harness now takes --model more than once, each model with its own Wilson CI, benign-FPR, and recorded token/$ cost. A 2026-08-08 run adds the larger gpt-4o-2024-05-13 (ASR 72% → 22%, +50pp) next to gpt-4o-mini (42% → 12%, +30pp). The harness is ready to widen this past one family — the model-registry shim registers current Claude ids and Together is wired — so a keyed run over OpenAI + Anthropic + Together at the power-calc-sized 163 pairs/arm (RESULTS.md "Widening plan") would replace this scoped estimate with a real cross-family number. #123 was resolved by putting the scope inside the claim rather than carrying an over-broad headline.


┌────────────────────────────────────────────────────────────────┐
│  🤖 AI Agent: "Let me help clean up disk space..."            │
│                           ↓                                    │
│               rm -rf / --no-preserve-root                      │
│                           ↓                                    │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  🛡️ AIRLOCK: BLOCKED                                     │  │
│  │                                                          │  │
│  │  Reason: Matches denied pattern 'rm_*'                   │  │
│  │  Policy: STRICT_POLICY                                   │  │
│  │  Fix: Use approved cleanup tools only                    │  │
│  └──────────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────────┘

🧩 Where this sits — beneath the gateway, at the execution boundary

Why the boundary matters now. Gartner projects 40% of enterprise apps will ship a task-specific AI agent by end-2026, up from under 5% in 2025 (Gartner, Aug 2025) — roughly an order-of-magnitude jump, in one year, in untrusted model-generated tool calls arriving at the execution boundary. That is the surface a gateway routes to but does not open up and inspect.

MCP gateways and agent-identity control planes — Microsoft Entra Agent ID, Databricks Unity AI Gateway, Docker MCP Gateway, Cloudflare, AWS, and the MCP spec's OAuth resource-server mandate — secure the transport and identity layer: who is allowed to connect, over what channel, with which token. They route and authenticate; that is necessary and they do it well.

agent-airlock sits one layer in, at the execution boundary after auth: it validates the actual tool-call payload the model produced — argument types (strict Pydantic, no coercion), hallucinated / ghost arguments, deny-by-default tool and capability scope, and output sanitization — then returns a self-healing error the model can retry against. A gateway can confirm the caller is authenticated; it does not check that transfer(amount=-1) is type-valid or that the agent picked the least-privileged tool for the task.

Reproduced, not asserted (re-measured live 2026-08-17): the same 12 malformed tool-call payloads were pushed through a live Docker MCP Gateway v2.0.1 and through airlock. The gateway forwarded 12/12 to the backend (its logs confirm it scanned for secrets and applied no-new-privileges — but not the argument contract); airlock blocked 12/12 at the contract layer, with 0/3 false positives on the benign controls. Reproduce with python -m benchmarks.vs_gateway (no Docker needed — it replays the recorded gateway measurement; regenerate that with benchmarks/vs_gateway/gateway_harness/). Full reproducible deep-dive — method, per-payload table, and an honest read of where a native gateway is already enough vs. where the in-process contract layer adds value: docs/benchmarks/mcp-gateway-payload-gap.md.

Why the payload contract can't move to the gateway (MCP 2026-07-28). The final spec made the transport stateless — SEP-2575 removed the initialize→session handshake and the Mcp-Session-Id header, and SEP-2567 pushed state into ordinary typed tool arguments. A gateway now sees independent, session-less requests and routes on the Mcp-Method / Mcp-Name headers (SEP-2243) without inspecting the body — by design. That is exactly the anchor a schema-composition check needs and no longer has: validating that oneOf / anyOf / allOf / $ref contract is neither ambiguous nor open requires the tool's whole inputSchema document, its $defs, and branch-selection logic resolved against the actual arguments — a per-call, per-tool computation at the function boundary, not a routing decision at the edge. And SEP-2106 forbids the one shortcut a gateway might take: an external $ref must not be auto-dereferenced, because a fetched schema is attacker-controlled input that would redefine the contract at call time. airlock does this statically (airlock scan-tools) and denies external refs + branch-ambiguous surfaces deny-by-default (mcp_schema_2020_12_contract_defaults).

Use both. Gateway/OAuth for the connection; airlock as the in-process call-contract layer for the payload. It's a decorator, not a proxy — zero new network hops, runs wherever your tool runs.

Propose-versus-authorize: agent-safe-pipeline and toolpermit

Two projects landed in the same week of August 2026 that people reasonably compare airlock against. Both are worth reading first, and the honest framing is that they answer a different question. They describe where the authorization boundary should sit and who gets to decide. airlock is the in-process check that runs beneath that boundary, on the call itself: strict validation, ghost-argument stripping, deny-by-default typing. A reference architecture and a type-checker are complementary layers. Claiming to replace either would be less credible than saying so.

decionis/agent-safe-pipeline (TypeScript, Apache-2.0, created 2026-08-13) is a reference architecture for the rule agents propose, policy decides: an immutable canonical intent, an independent ALLOW / ESCALATE / BLOCK verdict, verified human approval, and a SafeExecutor that consumes a single-use intent-bound grant. Its trust-boundary doc is explicit that the agent is untrusted even in-process, and that meaningful enforcement means putting the executor behind a separate service boundary.

sunhao123456sun-svg/toolpermit (Python, Apache-2.0, created 2026-08-11) is a local-first stdio MCP proxy: strict YAML allow / ask / deny policy where first match wins, a one-time approval bound to the canonical request and consumed atomically, irreversible redaction before SQLite persistence, and offline replay of recorded calls against a candidate policy.

What changed in v0.8.74. Writing the comparison above is what surfaced #143: both projects centre a three-state decision (allow / ask / deny, ALLOW / ESCALATE / BLOCK) and airlock had two, so the asymmetry could not be stated without over- or under-claiming. airlock now has the third state. SecurityPolicy(escalate_tools={"wire_*": "wires need a second pair of eyes"}, approver=...) raises PolicyEscalation and routes to the approver that agent_airlock.oversight has shipped since v0.8.4.

Two things this deliberately is not. It is not an approval UI, a request store, or a resumable pending-call queue — the projects above own that workflow and do it well; airlock owns a function call. And the approver is synchronous, which is a real cost in a hot path and is called out in #143 itself. What airlock adds is the in-process context: the argument values, the resolved policy, the capability set, and the tier budget are all already in hand at the moment of decision, so "transfers under $500 proceed, over $500 ask a human" is expressible as a property of the call rather than of the function. An escalation with no approver registered blocksPolicyEscalation subclasses PolicyViolation, so deny-by-default is enforced by the type lattice rather than by a runtime branch.

Which one you actually want

Use agent-safe-pipeline if you are designing the authorization boundary from scratch and the open question is who is allowed to authorize this action. It is the better choice there, and by a clear margin: it is a worked model for intent capture, independent verdicts, and human escalation, and it will shape the architecture in ways a validation library cannot.

Use toolpermit if you want a human approval prompt and a redacted audit trail in front of an existing stdio MCP server without touching that server's code. Drop the proxy in and write the policy. Its ask is policy-driven — the rule decides which calls need a human, and a loopback UI collects the answer. airlock closed most of that gap in v0.8.74: a SecurityPolicy can now escalate — escalate_tools raises PolicyEscalation and routes to the requires_human_oversight approver (#143, worked example). What toolpermit still has and airlock does not is the UI and the store: airlock's approver is an operator-supplied callable with no loopback prompt, no pending-request queue, and no redacted SQLite trail. If you want the human prompt itself rather than the hook to build one, that is toolpermit.

Use agent-airlock if the tool function is yours and the open question is whether this specific payload is well-formed. Strict types with no coercion, arguments the model invented, deny-by-default scope, checked at the function boundary with no new network hop.

What airlock does not do, stated plainly because the boundary matters more than the feature list: there is no approval UI, no human-verification service, and no notion of an action being authorized in the business sense. It is not a proxy, so it never sees a call to a tool you did not decorate. It answers "is this call well-formed and in scope", which is a narrower question than either project above is asking — and one they both leave to the layer underneath.


📊 Benchmarks: AgentDojo (that wedge, measured — not asserted)

The gateway argument above is measurable, so we measured it on AgentDojo (Debenedetti et al., NeurIPS 2024), a third-party model-in-the-loop adaptive-attacker benchmark. airlock is registered as an AgentDojo defense at the tool-execution seam (deny-by-default least-privilege SecurityPolicy + ghost-argument BLOCK + output sanitizer), then run against the tool_knowledge injection attack across all 4 suites (workspace, banking, travel, slack; benchmark v1.2.1).

Result 1 — deterministic block coverage (free, offline, no model):

Suite injection→task pairs blocked block rate
workspace 240 222 92.5%
banking 144 102 70.8%
travel 120 114 95.0%
slack 105 86 81.9%
Combined 609 524 86.0%

86.0% of tool_knowledge injection→task pairs have their target tool-call blocked by airlock's least-privilege policy — a deterministic upper bound on ASR reduction, not the model-in-the-loop ASR.

Result 2 — model-in-the-loop utility-under-attack + ASR (the leaderboard metrics): run the --model path with a key; it regenerates RESULTS.md with a baseline vs airlock table (benign utility / utility-under-attack / ASR / the defense's utility cost).

The honest miss: a suite-wide allow-list catches far fewer (0% on banking — injections there abuse a legitimate tool like send_money with a malicious recipient). Least-privilege scoping per task is what does the work; argument-level abuse of an allowed tool needs airlock's strict Pydantic validation, not the tool-level policy alone. We report the misses — no "blocks everything" claim.

pip install "agent-airlock[bench]"
python -m benchmarks.agentdojo.run                                   # Result 1 (deterministic)
python -m benchmarks.agentdojo.run --model gpt-4o-mini-2024-07-18 \
  --out benchmarks/agentdojo/RESULTS.md                              # Result 2 (needs OPENAI_API_KEY)

Full method, per-suite caveats, and the gateway cross-link: benchmarks/agentdojo/RESULTS.md.


🔍 Scan then enforce (with agent-audit-kit)

airlock is the runtime half of a two-layer story. Its sibling scanner, agent-audit-kit"the missing npm audit for AI agents" — is the build / CI / IDE-time half: a deterministic, offline static scanner for MCP-connected pipelines (AST taint analysis; MCP-config, supply-chain, tool-poisoning, secret, transport, and trust-boundary rules; automated CVE feeds) that ships as a CLI, GitHub Action, pre-commit hook, and VS Code extension, and emits SARIF for the GitHub Security tab.

They sit at different points in the lifecycle and compose cleanly — scan before deploy, enforce at runtime:

  1. Scanagent-audit-kit scan . finds unguarded surfaces before they ship: an MCP tool with additionalProperties left open, a poisoned server-card description, a subprocess/argv injection surface, an unsigned _meta trust decision.
  2. Enforce — wrap the tool with @Airlock(...) (or opt into the matching preset — mcp_meta_trust_2026_07_defaults, mcp_schema_2020_12_contract_defaults, mcp_spec_2026_07_header_integrity_defaults, …) so the same class is blocked in production, at the function boundary, not merely flagged in a report.
  3. Pre-flightairlock scan-tools is airlock's own narrow static check for the exact contracts its runtime guards enforce; run it in CI alongside the broader audit-kit scan, and use --output sarif so both feed the same GitHub Security tab.

The two stay separate packages on purpose: a static scanner carries a rule database and CVE feeds that update on their own cadence, while airlock's runtime core stays Pydantic-only and zero-dep. Use the scanner to find what to guard; use airlock to guard it.


🎯 30-Second Quickstart

pip install agent-airlock
from agent_airlock import Airlock

@Airlock()
def transfer_funds(account: str, amount: int) -> dict:
    return {"status": "transferred", "amount": amount}

# LLM sends amount="500" (string) → BLOCKED with fix_hint
# LLM sends force=True (invented arg) → STRIPPED silently
# LLM sends amount=500 (correct) → EXECUTED safely

That's it. Your function now has ghost argument stripping, strict type validation, and self-healing errors.


🧠 The Problem No One Talks About

The Hype

"MCP has 16,000+ servers on GitHub!" "OpenAI adopted it!" "Linux Foundation hosts it!"

The Reality

LLMs hallucinate tool calls. Every. Single. Day.

  • Claude invents arguments that don't exist
  • GPT-4 sends "100" when you need 100
  • Agents chain 47 calls before one deletes prod data

Enterprise solutions exist: Prompt Security (now part of SentinelOne), Pangea (proxy your data), Cisco ("coming soon"). All are quote-based; none publishes list pricing.

We built the open-source alternative. One decorator. No vendor lock-in. Your data never leaves your infrastructure.


✨ What You Get

shield
Ghost Args
Strip LLM-invented params
check
Strict Types
No silent coercion
refresh
Self-Healing
LLM-friendly errors
lock
E2B Sandbox
Isolated execution
user
RBAC
Role-based access
privacy
PII Mask
Auto-redact secrets
network
Network Guard
Block data exfiltration
folder
Path Validation
CVE-resistant traversal
circuit
Circuit Breaker
Fault tolerance
otel
OpenTelemetry
Enterprise observability
cost
Cost Tracking
Budget limits
vaccine
Vaccination
Auto-secure frameworks

📋 Table of Contents

Click to expand full navigation

🔥 Core Features

🔒 E2B Sandbox Execution

from agent_airlock import Airlock, STRICT_POLICY

@Airlock(sandbox=True, sandbox_required=True, policy=STRICT_POLICY)
def execute_code(code: str) -> str:
    """Runs in an E2B Firecracker MicroVM. Not on your machine."""
    exec(code)
    return "executed"
Feature Value
Boot time ~125ms cold, <200ms warm
Isolation Firecracker MicroVM
Fallback sandbox_required=True blocks local execution

Air-gapped / on-prem? DockerBackend is the supported alternative — cap_drop=["ALL"], no-new-privileges, network_mode="none", timeout enforced, opt-in pytest -m docker integration tests. See docs/sandbox/docker.md.

ModalBackend — Modal-hosted sandbox (v0.8.11+, issue #30)

Already running the rest of your agent on Modal? ModalBackend lets you keep airlocked tool execution on the same substrate instead of mixing E2B and Modal billing / observability.

pip install "agent-airlock[modal]"
from agent_airlock import Airlock, STRICT_POLICY, AirlockConfig
from agent_airlock.sandbox_backend import ModalBackend

backend = ModalBackend(
    app_name="my-airlock-sandbox",
    image_ref="python:3.11-slim",
    cpu=0.5,
    memory_mb=512,
    timeout_s=30,
    # network_policy=None  → block_network=True (fail-closed default)
)

@Airlock(sandbox=True, sandbox_required=True, policy=STRICT_POLICY,
         config=AirlockConfig(sandbox_backend=backend))
def execute_code(code: str) -> str:
    exec(code)
    return "executed"

Isolation model — read before you reach for cap_drop. Modal sandboxes run under gVisor (kernel-syscall filtering), not under Docker-style capability dropping. The Modal Python SDK does not expose cap_drop / cap_add / seccomp / no-new-privileges — there is no equivalent knob to map. If your threat model needs Linux-capability dropping at the container layer, keep using DockerBackend. The network posture is configurable: ModalBackend defaults to block_network=True (deny-by-default), and a supplied NetworkPolicy maps to Modal's block_network flag (allow_egress=False → blocked, True → allowed). Hostname allowlists in NetworkPolicy.allowed_hosts do not forward to Modal (their API is CIDR-only); the backend logs a structlog warning and the operator is expected to re-state hostname constraints at the Airlock policy layer.

ModalBackend is opt-in only — it is NOT added to the get_default_backend() priority chain (E2B → Docker → Local stays the default flow). Existing callers see no behavior change.


📜 Security Policies

Preset Use case Key posture
PERMISSIVE_POLICY Dev / sandbox No restrictions
STRICT_POLICY Prod Rate-limited, requires agent identity, denies dangerous capabilities
READ_ONLY_POLICY Analytics / RAG read_* / get_* / list_* / search_* only
BUSINESS_HOURS_POLICY Compliance windows delete_* / drop_* / *_production only 09:00–17:00
CAMOUFLAGE_RESISTANT_POLICY (v0.8.6) Detector-independent defense vs. domain-camouflaged injection Deny-by-default allowlist, ghost-arg BLOCK, output cap, per-call reauthorization
from agent_airlock import (
    PERMISSIVE_POLICY,
    STRICT_POLICY,
    READ_ONLY_POLICY,
    BUSINESS_HOURS_POLICY,
    CAMOUFLAGE_RESISTANT_POLICY,  # v0.8.6
)

# Or build your own:
from agent_airlock import SecurityPolicy

MY_POLICY = SecurityPolicy(
    allowed_tools=["read_*", "query_*"],
    denied_tools=["delete_*", "drop_*", "rm_*"],
    rate_limits={"*": "1000/hour", "write_*": "100/hour"},
    time_restrictions={"deploy_*": "09:00-17:00"},
)

CAMOUFLAGE_RESISTANT — detector-independent injection defense (v0.8.6)

arXiv:2605.22001 ("Blind Spots in the Guard", Pai, May 2026) shows that production injection detectors — Llama Guard 3 included — drop to IDR = 0.000 on payloads that mimic the target document's domain vocabulary and authority structure. Per the paper, detection rates collapse from 93.8% to 9.7% on Llama 3.1 8B and from 100% to 55.6% on Gemini 2.0 Flash.

CAMOUFLAGE_RESISTANT_POLICY does not rely on payload-content signatures at all. It blocks at four structural seams an attacker has to ride regardless of phrasing:

  1. Deny-by-default tool allowlist. Empty allowed_tools means nothing is callable; deployments opt every tool in by name. A camouflaged directive targeting an unlisted tool is blocked on allowlist grounds without ever invoking a detector.
  2. Ghost-argument BLOCK. A camouflaged directive cannot smuggle undeclared parameters past validation.
  3. Hard output cap + sanitization. Tool output that re-enters the model context is truncated and PII/secret-masked so a camouflaged directive embedded in tool output can't carry into a downstream agent at full length.
  4. Per-call reauthorization (debate-amplification guard). Once a tool's output has flowed back into the model, any reinvocation requires an explicit context.authorize_once(tool) grant from the harness — breaking the multi-agent fan-out path the paper identifies.
from agent_airlock import Airlock, apply_camouflage_resistant

bundle = apply_camouflage_resistant(allowed_tools=["read_file", "search"])

@Airlock(config=bundle.config, policy=bundle.policy)
def read_file(path: str) -> str:
    ...

apply_camouflage_resistant() composes the matching AirlockConfig (unknown-args BLOCK, sanitization on, output cap 4000 chars) with a SecurityPolicy carrying your explicit allowlist. The preset is deliberately incomplete on its own — the config-level knobs and the policy-level knobs span two seams, so the factory returns both as a CamouflageResistantBundle.

Running an MCP server with STDIO transport? Also wire the Ox MCP STDIO sanitizer via stdio_guard_ox_defaults() — it blocks the entire CVE-2026-30616 class (shell metacharacter injection, non-allowlisted binaries, Trojan-Source RTL overrides, and inline-code flags) before subprocess.Popen.


🪪 MCP server attestation (v0.8.10)

arXiv:2605.24248 ("Attested Tool-Server Admission", Metere, May 2026) calls out a gap MCP itself does not close: the protocol standardises message exchange between LLM agents and tool servers but says nothing about trust. Anybody who can answer on the wire can declare themselves a tool server.

mcp_attested_admission_defaults() is a deny-by-default opt-in preset that closes the gap host-side, mirroring the paper's three additive mechanisms:

  1. Offline-signed clearance assertion. Before any tool from an MCP server is dispatched, the host fetches a JWS-compact clearance from {server_url}/.well-known/mcp-clearance (path is configurable) and verifies its signature against an operator-pinned trust root. The trust root is supplied to AttestedAdmissionConfig at process startup — never network-fetched on the hot path.
  2. Deny-by-default per-server tool allowlist. Admitting a server is not the same as trusting its every tool. The verified clearance carries an explicit list of tool names the host will permit; everything else is denied. The sub claim is matched against the server identity the host is about to dispatch to (so a stolen clearance from server A can't admit a tool call to server B).
  3. Flavor-gated enforcement. ENFORCE (default) hard-denies on missing / invalid / expired clearance; WARN logs and admits — the staged turn-up an operator wants when introducing the gate against real traffic.

Every admission decision emits a ReceiptVerdict on the guard="mcp_attested_admission" channel, so the existing airlock attest DSSE pipeline picks decisions up unchanged — this preset does not invent a new log.

from agent_airlock.mcp_proxy_guard import MCPProxyConfig, MCPProxyGuard
from agent_airlock.mcp_spec.attested_admission import TrustRoot
from agent_airlock.policy_presets import mcp_attested_admission_defaults

# Operator pins the trust root at startup. Never fetched at runtime.
with open("/etc/airlock/mcp-clearance-root.pem", "rb") as fh:
    pinned_pem = fh.read()

cfg = mcp_attested_admission_defaults(
    trust_root=TrustRoot(key_id="ops-2026Q2", ed25519_pem=pinned_pem),
    enforcement_mode="ENFORCE",       # deny-by-default
    max_clearance_age_days=30,
)
guard = MCPProxyGuard(MCPProxyConfig(attested_admission=cfg))

decision = guard.audit_tool_admission(
    server_url="https://mcp.example.com",
    server_id="srv-alpha",            # expected `sub` claim
    tool_name="read",
)
if not decision.admitted:
    raise RuntimeError(decision.reason)

Signature verification needs the [attested] extra (pulls in cryptography for offline Ed25519 / RSA-PSS / JWKS verification); the base install stays zero-runtime-dep.

Install with pip install "agent-airlock[attested]". Opt-in only — existing callers that don't set attested_admission get exactly v0.8.9 behavior.


🧭 Behavioral sequence guard (v0.8.12)

Watches the ordered stream of tool calls in a session and flags divergence from a declared expected order — not the model's stated reasoning trace.

arXiv:2605.27901 ("The Fragility of Chain-of-Thought Monitoring", Onyame, Zhou, Thopalli, Kailkhura & Agarwal, May 2026) reports an average 95.9% CoT unfaithfulness across 8B–120B-parameter models — including answer-switching, post-hoc rationalisation, and procedural exploitation of hints. Trusting the model's stated reasoning to detect misbehavior is therefore not viable. Trusting its behavior — the sequence of tools it actually invokes — is.

SequenceGuard is an opt-in field on SecurityPolicy that runs in the @Airlock seam right after the standard policy check, in two modes:

DECLARED mode — operator supplies a permitted-transition DAG. Any transition not in the DAG is a SequenceViolation. Deny-by-default.

from agent_airlock import Airlock, SecurityPolicy
from agent_airlock.sequence_guard import SequenceGuard, ENTRY_SENTINEL

policy = SecurityPolicy(
    sequence_guard=SequenceGuard(
        mode="declared",
        action="block",                       # or "warn"
        dag={
            ENTRY_SENTINEL: {"read"},         # only `read` may start a session
            "read": {"read", "summarize"},    # after read, either re-read or summarize
            "summarize": {"send"},            # after summarize, only send
            "send": set(),                    # send is terminal
        },
    ),
)

BASELINE mode — guard maintains a per-session-key Markov transition profile in a local JSON file (no cloud, no PII — only tool names and SHA-256 shape hashes of (arg types, kwarg names+types), never argument values) and flags transitions with observed P(curr | prev) < threshold once the sample size from prev reaches min_baseline_samples.

from pathlib import Path
from agent_airlock.sequence_guard import SequenceGuard

policy = SecurityPolicy(
    sequence_guard=SequenceGuard(
        mode="baseline",
        baseline_path=Path("/var/lib/airlock/sequence-baseline.json"),
        low_probability_threshold=0.05,   # flag the bottom 5%
        min_baseline_samples=50,          # don't flag until 50 obs from `prev`
    ),
)

Every flagged transition emits OTel span attributes on the current span (airlock.sequence_guard.mode, .from_tool, .to_tool, .session_key, .observed_probability) via the existing observability provider — telemetry failures are swallowed so they cannot break enforcement.

Not AnomalyDetector (that's rate / endpoint-diversity / error-rate / consecutive-blocked over sliding windows). SequenceGuard is per-transition ORDER signal. Run both for layered coverage. Not a chain-of-thought monitor — by construction.

Strictly opt-in. The new SecurityPolicy.sequence_guard field defaults to None; callers that don't set it get exactly v0.8.11 behavior. Zero new runtime deps — Pydantic-only core stays intact.

DECLARED mode is also the one control here that catches a detour-hijacking trajectory (arXiv:2608.12273), where a malicious skill description recruits unnecessary benign skills and then re-enters the original route so the task still completes. It catches it only if you declared the route first, and the honest limits — what the audit log does and does not show about the resulting cost — are written up in docs/security/detour-hijacking.md.


🛑 Action-time contradiction gate (v0.8.15)

arXiv:2605.27157 ("Detecting Is Not Resolving: The Monitoring Control Gap in Retrieval Augmented LLMs", Yu et al., 2026) shows that LLMs readily acknowledge contradictory evidence in their reasoning trace yet "this awareness fails to constrain their final recommendations". The deficit is at action selection — single-turn diagnostics overestimate RAG safety, and detection alone is not a control.

ActionContradictionGate is an opt-in policy hook that wraps three pluggable detectors (any one trips) and a privileged-sink glob set. When a detector trips AND the dispatched tool matches a privileged sink AND the harness has not issued an explicit allow, the gate blocks the call (or warns, depending on action=).

The explicit-allow primitive is not new — the gate reuses the existing AirlockContext.authorize_once(tool_name) (introduced for the v0.8.6 reauth flow). Same one-shot grant, same semantics. After a one-shot is consumed the gate re-locks — the harness must mint a fresh authorize_once for each privileged action.

import re
from agent_airlock import Airlock, SecurityPolicy
from agent_airlock.action_contradiction_gate import ActionContradictionGate

policy = SecurityPolicy(
    action_contradiction_gate=ActionContradictionGate(
        # Detector 1: a boolean flag the RAG pipeline flips on after
        # it sees an evidence-vs-claim conflict the agent discussed.
        signal_field_key="evidence_contradiction",
        # Detector 2: pluggable regex against the SAME key when its
        # value is a string (operator-controlled marker — never the
        # model's full reasoning trace).
        marker_regex=re.compile(r"contradict|conflict|disagree", re.I),
        # Detector 3: fully pluggable callable; receives the context.
        # predicate=lambda ctx: ctx.metadata.get("conflict_count", 0) > 1,
        # Default privileged sinks: send_* / export_* / commit_* /
        # transfer_* / delete_* + the v0.8.14 outbound-integration set.
        # Operators can narrow via `privileged_sinks=(...)`.
        action="block",  # or "warn" for staged turn-up
    ),
)

Off-by-default invariant. SecurityPolicy.action_contradiction_gate defaults to None; non-RAG flows pay zero false-positive tax (no detector runs, no log lines, no metadata reads). Even when wired, the gate is inert until at least one detector slot is configured — so a partial roll-out (gate attached but detectors flipped off) admits everything.

Not a chain-of-thought monitor. The gate reads operator- controlled signals only (a metadata field, an operator regex, an operator predicate). It never reads the model's own claim that it has or has not noticed a contradiction — the paper's whole point is that those claims do not gate behavior.

Not sequence_guard (v0.8.12) — that flags unusual call ORDER. Not reauth_on_untrusted_reinvocation (v0.8.6) — that's count-driven on a per-tool counter. This gate is signal-driven and targets a specific privileged-sink glob set. They compose; run all three for layered coverage.

Strictly opt-in. Zero new runtime deps — Pydantic-only core stays intact. The new SecurityPolicy.action_contradiction_gate field defaults to None; callers that don't set it get exactly v0.8.14 behavior.


📊 Adversarial-negotiation regression harness (v0.8.17)

A deterministic harness that measures what the deny-by-default governance layer does to a fixed set of adversarial buyer-seller negotiation actions — and reports two metrics named to line up with an external published baseline so the numbers can sit side by side.

airlock negotiation-bench --report markdown

Each scenario carries a concrete, checkable unsafe action and runs twice — baseline (no airlock, the unsafe event lands) and governed (the same action through the real @Airlock intercept-before-execute path, no policy-layer mocking). Three unsafe-action classes each exercise a different real interception mechanism: price-below-floor → Pydantic strict-validation, secret-leak → the output sanitizer, transfer-outside-policy → deny-by-default SecurityPolicy. Benign deals are included to confirm governance does not over-block.

source unsafe_execution_rate (base → governed) valid_task_success_rate (base → governed)
agent-airlock (this harness) 100% → 0% 43% → 100%
OCL (external, live LLMs, arXiv:2606.04306) 88% → ~0% 12% → 96%

The OCL row is an external result, not agent-airlock's. It was measured on live frontier LLM agents in AgenticPay-adapted negotiation (OCL, arXiv:2606.04306; AgenticPay, arXiv:2602.06008) and is reproduced here only for directional comparison — both put governance at the execution boundary. It is not the same experiment: agent-airlock is a deterministic execution-boundary validator, not an LLM, and this harness does not call a model. The agent-airlock rows are a property of the policy layer under a worst-case scripted adversary, exercised through the real @Airlock path.

The harness doubles as a regression gate: --fail-if-governed-unsafe exits non-zero if the governed unsafe_execution_rate ever rises above zero, so a future change that weakens the policy layer fails CI. Zero new runtime deps; fully deterministic (no randomness, no network, no model call).


🔎 Privilege right-sizing — airlock explain --unused-scopes (v0.8.13)

A read-only CLI that surfaces over-permissioning: it diffs the SecurityPolicy's granted tool scopes against the tools the agent actually called (from an OTLP export OR a native audit JSONL), per AgentIdentity, and prints the dead-weight set plus a suggested tightened allow-list.

# Install; the unified `airlock` CLI becomes available
pip install "agent-airlock>=0.8.56"

# Diff granted vs used; print a table
airlock explain --unused-scopes \
    --policy ./security-policy.toml \
    --trace  ./agent.audit.jsonl

# Same, machine-readable, plus a proposed tightened policy preview
airlock explain --unused-scopes \
    --policy ./security-policy.toml \
    --trace  ./otel-export.json \
    --format json \
    --suggest-policy

Observability-only. This command never mutates the SecurityPolicy, never writes the policy file, and never auto-applies the suggestion. The deny-by-default posture is unchanged — the right-size CLI is a review aid, not an enforcement primitive. The --suggest-policy output is intentionally a stdout preview so a human reviews the tightened allow-list before adopting it by hand.

Trace formats (auto-detected by inspecting the file head):

  • Audit JSONL — the format AuditLogger already emits. One JSON object per line, with tool_name / agent_id / blocked. Blocked calls are excluded from the "actually called" set — a blocked call is not an exercise of a granted scope.
  • OTLP JSON — the format opentelemetry-exporter-otlp writes. Span name is the tool name; attributes.agent_id keys the per- agent diff. If a span carries airlock.blocked=true it is skipped, same as JSONL.

Diff semantics. The matcher is fnmatch — the same glob semantics SecurityPolicy.check_tool_allowed uses internally, so the suggested tightened allow-list admits exactly the tools the agent was observed calling (no surprises at adoption time). Denied-list patterns are forwarded unchanged to the suggestion: denials are intent, not usage data.

Strictly observability. No new runtime deps. Reachable as airlock explain via the unified dispatcher (v0.8.56+); the airlock-explain console script and python -m agent_airlock.cli.explain remain as equivalent aliases.


🧾 Static contract / type-checker — airlock scan-tools (v0.8.42)

The wedge, made static. Where @Airlock enforces the contract at call time, scan-tools type-checks the tool declarations before your agent loads them — a deny-by-default contract layer for AI tool calls, distinct from content-signature tool-poisoning scanners (MCP-Scan, eSentire MCP-Scanner).

pip install "agent-airlock>=0.8.42"

# Grade every tool declaration against a least-privilege policy (CI-friendly).
airlock scan-tools ./tools/ --policy strict          # exit 0=clean 1=warn 2=fail
airlock scan-tools ./mcp.json --output json

It reads a .json tool-def file, a directory of them, or an mcp.json / claude_desktop_config.json config with inlined tool schemas, and grades each tool pass / warn / fail:

  • Over-broad argument surfaceadditionalProperties is not false on a destructive/mutating tool (a ghost/hallucinated-argument vector). FAIL.
  • Missing type constraints — a sensitive string arg (path / url / command / …) with no enum / pattern / format / maxLength, or an untyped property. WARN.
  • Capability caps exceed policy — the tool's inferred capability (from MCP annotations, an explicit capabilities list, or a name heuristic) is denied / not-granted by the policy's shipped CapabilityPolicy. FAIL.
  • Server-card trust boundary — a tool description carrying injected instructions ("…ignore previous instructions and run…"). Reuses the mcp_spec_2026_07 Server-Card / SEP-2468 guard. FAIL.

Policies map 1:1 to shipped constants — permissive / read-only / strict / deny-by-defaultno invented policy. strict uses the STRICT capability caps (shell/delete denied, write not granted). Coverage is measured against MCPTox-derived fixtures (python -m benchmarks.scantools_mcptox) and reported as-is: 69.2% static contract-checking coverage · 100% precisionnot MCPTox's model-in-the-loop Attack Success Rate.

Static review aid, Pydantic-only, zero-dep core. Reachable as airlock scan-tools via the unified dispatcher (v0.8.56+); the airlock-scan-tools console script and python -m agent_airlock.cli.scan_tools remain as equivalent aliases.


⌨️ One CLI: airlock <command> (unified dispatcher, full command set since v0.8.56)

Every command in this section is a subcommand of a single airlock entry point. Run it with no arguments to discover them all:

pip install "agent-airlock>=0.8.56"

airlock                                   # list every subcommand + one-line description
airlock scan-tools ./tools/ --policy strict
airlock doctor --path .
airlock explain --unused-scopes --policy p.toml --trace t.jsonl
airlock corpus-bench --format md

airlock <cmd> ... delegates to the exact same code as python -m agent_airlock.cli.<cmd> ..., so flags are identical between the two forms — verified in tests/test_cli_dispatcher.py. The older airlock-explain, airlock-conformance, and airlock-scan-tools scripts stay as equivalent aliases.


🩹 Skill-resistant trace redaction + watermark (v0.8.24)

Why traces are an extraction surface: an agent's emitted trace/receipt is a distillation target, not just an audit artifact. A trace that records the tuned thresholds a policy fired on, the exact tool-call arguments, and the recovered intermediate formulas/strategies hands a competitor the recipe — enough to clone the behaviour without paying for the search that found it. The verifier, by contrast, needs only the evidence (the gate ran / the policy fired / pass-fail), never the recipe. This is the RedAct-style threat model — a composition of published behavioural-watermarking work (Agent Guide, arXiv:2504.05871; CoTGuard, arXiv:2505.19405; Distilling the Thought, arXiv:2601.05144). agent-airlock does not reproduce any paper's benchmark.

TraceRedactionPolicy (opt-in, OFF by default for backward compat, ON under STRICT_POLICY) runs at the non-local sink (e.g. the OTel exporter): it (a) localizes protected fields with a configurable field-classifier (tuned thresholds, tool-call args, recovered formulas/strategies), (b) rewrites them to keep verifier-critical evidence while dropping the recipe, and (c) embeds a per-tenant behavioural watermark so a leaked trace is provably yours. Detect it with airlock trace verify-watermark <trace.json> (cryptographic keyed-HMAC match → high true-detection, low false-alarm); add --redaction-report to see what was localized / rewritten / preserved. Stdlib-only — no new runtime dependency.

from agent_airlock import TraceRedactionPolicy, trace_redact, verify_watermark

pol = TraceRedactionPolicy(enabled=True, tenant_id="acme-co", watermark_secret="...")
redacted, report = trace_redact(trace, pol)   # tuned_threshold → evidence stub; recipe dropped
assert verify_watermark(redacted, pol).detected   # provably yours

✅ Fail-closed terminal-claim guard — no_false_success (v0.8.25)

An honest stall is recoverable; a confident wrong done is not. The dominant failure mode of unattended long-horizon agents isn't crashing — it's confidently reporting success they never verified (Goal-Autopilot, arXiv:2606.11688). The no_false_success preset enforces that paper's No-False-Success floor: a terminal/done claim is admitted only if a named, falsifiable check actually executed and passed THIS run. No receipt, a failed check, or a forged/replayed receipt → the guard fails closed to a recoverable honest stall (run the named check and retry), never a fabricated success.

Forgery resistance is structural: the guard mints a per-run token and only trusts a receipt it stamped by executing the check this run — a receipt that's merely present (hand-built, or replayed from a prior run) is rejected. Opt in per-agent with AirlockConfig(require_done_receipt=True) (or require_done_receipt = true under [airlock] in airlock.toml); OFF by default. Stdlib-only — no new runtime dependency.

from agent_airlock import no_false_success_defaults, NoFalseSuccessStall

preset = no_false_success_defaults({"tests_green": run_pytest})  # falsifiable check
preset["guard"].run("tests_green")        # actually execute the check this run
preset["check"]("tests_green")            # raises NoFalseSuccessStall unless it passed

🔗 Capability-union boundary at grant time (v0.8.70)

A tool that can read files and a tool that can send data are each fine on their own, but held together they are an exfiltration path — the "lethal trifecta" capability combination named at Black Hat USA 2026. CapabilityCapEngine.grant_lease evaluates the union of capability the calling context would hold if a lease were granted and denies {filesystem-read or credential} + {non-allowlisted network egress} by default — even when each lease is individually permitted — naming the prior lease that combined to trigger it. This evaluates at grant time, not call time, so it does nothing about capabilities acquired outside the lease system; the escape hatch is an explicit UnionOverride, recorded loudly in the tamper-evident decision log with the granting identity.

from agent_airlock.capability_caps import (
    CapabilityCapEngine, CapabilityRulesConfig, CapabilityCategory, Lease,
)

engine = CapabilityCapEngine(CapabilityRulesConfig())
engine.grant_lease("agent", Lease("l1", "read_config",
                                  frozenset({CapabilityCategory.FILESYSTEM_READ})))   # allowed alone
engine.grant_lease("agent", Lease("l2", "post_to_webhook",
                                  frozenset({CapabilityCategory.NETWORK_EGRESS})))     # CapabilityUnionDeniedError, names "l1"

📌 CIMD trust-anchor pinning (v0.8.71)

MCP 2026-07-28 replaces Dynamic Client Registration with Client ID Metadata Documents: the client_id is an https URL and the metadata document is served from that URL, by the client. The trust anchor moved from a registration record you hold to a document someone else can rewrite at any time — so "the client I approved" and "the client I am about to authorize" are now two fetches with a gap in between. CIMDGuard pins the canonicalised document on one explicit, attributed approval (no trust-on-first-use), re-resolves on every subsequent grant, and denies by default on drift, naming exactly which fields moved. A rotation is never auto-accepted; the only way forward is another explicit approve(). A document that 404s or loses a required field moves the client to denied, not back to unknown. Non-https, private-resolving, and cross-origin-redirecting client_ids are refused before the body is trusted.

from agent_airlock.mcp.cimd import CIMDGuard, SQLiteCIMDPinStore

guard = CIMDGuard(store=SQLiteCIMDPinStore("airlock.db"))
guard.approve("https://client.example.com/.well-known/oauth-client", approved_by="secops")

# Later, the client rewrites its own redirect_uris:
decision = guard.check("https://client.example.com/.well-known/oauth-client")
decision.verdict          # CIMDVerdict.DENY_DRIFT
[c.field for c in decision.changed_fields]   # ['redirect_uris']

Wired into the same grant_lease decision point as the capability-union boundary above, so a lease grant has one place that answers both "who is asking" and "what would they then hold":

engine = CapabilityCapEngine(CapabilityRulesConfig(), cimd_guard=guard)
engine.grant_lease("agent", lease, client_id="https://client.example.com/.well-known/oauth-client")
# CIMDTrustAnchorError if the anchor moved — evaluated before the union

It pins a document; it does not judge whether that document was trustworthy the first time, and it verifies at grant time rather than continuously. See docs/mcp/cimd-trust-anchor.md.


💰 Cost Control

A runaway agent can burn $500 in API costs before you notice.

from agent_airlock import Airlock, AirlockConfig

config = AirlockConfig(
    max_output_chars=5000,    # Truncate before token explosion
    max_output_tokens=2000,   # Hard limit on response size
)

@Airlock(config=config)
def query_logs(query: str) -> str:
    return massive_log_query(query)  # 10MB → 5KB

ROI: 10MB logs = ~2.5M tokens = $25/response. Truncated = ~1.25K tokens = $0.01. 99.96% savings.

Per-model-tier budgets (v0.8.7)

The flat max_output_* caps above apply uniformly to every call. ModelTierBudget caps per-call cost and output tokens per model tier label (e.g. "frontier" / "mid" / "small"), evaluated before the tool runs. Untagged calls fall back to a configurable strict_tier (deny-by-default — the cheapest tier).

from agent_airlock import (
    Airlock, ModelTierBudget, SecurityPolicy, TierBudget,
)

policy = SecurityPolicy(
    model_tier_budget=ModelTierBudget(
        tiers={
            "frontier": TierBudget(max_cost_cents=50, max_output_tokens=4000),
            "mid":      TierBudget(max_cost_cents=10, max_output_tokens=2000),
            "small":    TierBudget(max_cost_cents=2,  max_output_tokens=1000),
        },
        strict_tier="small",  # untagged → cheapest tier (deny-by-default)
    ),
)

@Airlock(policy=policy, return_dict=True)
def call_model(prompt: str, **_extra):
    return run_my_router(prompt)

# The router tags each call. Airlock blocks before the model fires.
call_model("Draft a tweet",  _airlock_tier="small",    _airlock_input_tokens=50)
call_model("Deep analysis", _airlock_tier="frontier", _airlock_input_tokens=200_000)
# →  AIRLOCK_BLOCK: Tier 'frontier' budget exceeded (worst-case 66¢ > cap 50¢)

Routing logic stays in the user's router. Three tagging routes are supported:

  1. _airlock_tier kwarg — stripped before the tool sees it.
  2. context.metadata["airlock_tier"] — set on a contextvar-stored AirlockContext by the router's session middleware.
  3. tier_resolver callbackModelTierBudget(tier_resolver=fn) where fn(model_id: str) -> tier_label lives in the caller's code. Airlock invokes the callback when context.metadata["model_id"] is set; it carries no vendor-specific model→tier table.

After execution, actual vs estimated cost is reconciled into the global CostTracker (observability — never blocks). See examples/model_tier_budget.py for all four patterns including composition with allow/deny lists.

A ready-to-use strict_tier_budget_policy() preset returns a SecurityPolicy seeded with the table above.


🔐 PII & Secret Masking

config = AirlockConfig(
    mask_pii=True,      # SSN, credit cards, phones, emails
    mask_secrets=True,  # API keys, passwords, JWTs
)

@Airlock(config=config)
def get_user(user_id: str) -> dict:
    return db.users.find_one({"id": user_id})

# LLM sees: {"name": "John", "ssn": "[REDACTED]", "api_key": "sk-...XXXX"}

13 PII types detected · 4 masking strategies · Zero data leakage

Opt-in regional PII (pii_locales)

Aadhaar / PAN / UPI / IFSC / India-mobile ship as SensitiveDataType members, but are not added to the default mask_pii=True set — to keep the surface zero-dep and US-shaped by default. v0.8.9 adds a pii_locales opt-in that pulls them in and tightens detection:

config = AirlockConfig(
    mask_pii=True,
    pii_locales=["in"],   # opt in to India-locale detection
)

@Airlock(config=config)
def lookup(query: str) -> str:
    return (
        "User: राम कुमार, "
        "Aadhaar: 234567890124, "       # → "********0124" (last-4 only, UIDAI standard)
        "PAN: ABCPE1234F, "             # → "AB******4F"  (first-2 + last-2; 4th char P = valid PAN entity)
        "UPI: alice@oksbi, "            # → "a***@oksbi"  (mask local part, keep @bank)
        "phone: 555-123-4567"           # still masked by existing PHONE regex
    )

Activated when "in" in pii_locales:

  • Aadhaar Verhoeff checksum gate — the existing Aadhaar regex is permissive (any 12-digit number starting 2-9 matches). With the opt-in, each match must also pass the UIDAI Verhoeff checksum, cutting the FP rate ~10x on random IDs / phone numbers.
  • PAN entity-char gate (v0.8.46) — a PAN-shaped match whose 4th character is not a valid holder-type code (P/C/H/F/A/T/B/L/J/G) is dropped, cutting false positives on random [A-Z]{5}[0-9]{4}[A-Z] strings.
  • India mobile (v0.8.46)SensitiveDataType.INDIA_MOBILE detects a +91 / 91 / trunk-0 prefixed 10-digit number starting 6-9 and masks to +91-XXXXX-<last3>. Prefix-required by design: a bare 10-digit run is already masked by the US-shaped PHONE detector, so India-mobile only claims the +91 forms and never shares a span with it.
  • Devanagari personal-name detectionPERSONAL_NAME_DEVANAGARI runs against the Unicode block U+0900–U+097F, with a small allowlist of common Hindi greetings / pronouns / interrogatives to keep ordinary prose from being masked. Conservative heuristic — production callers who need precise extraction should layer NER on top.

These align with India's DPDP Act 2023 (Digital Personal Data Protection); the india_dpdp_2023_policy() / apply_india_dpdp_2023() preset pair turns the tool/capability gate and these maskers on in one call. The additions were driven by the DPDP Consent-Manager rules (effective 2026-11-14), which sharpen data-fiduciary obligations for Aadhaar / PAN / mobile handling.

The flag is additive and reversiblepii_locales=[] (the default) preserves the prior behavior bit-for-bit.


🌐 Network Airgap (V0.3.0)

Block data exfiltration during tool execution:

from agent_airlock import network_airgap, NO_NETWORK_POLICY

# Block ALL network access
with network_airgap(NO_NETWORK_POLICY):
    result = untrusted_tool()  # Any socket call → NetworkBlockedError

# Or allow specific hosts only
from agent_airlock import NetworkPolicy

INTERNAL_ONLY = NetworkPolicy(
    allow_egress=True,
    allowed_hosts=["api.internal.com", "*.company.local"],
    allowed_ports=[443],
)

💉 Framework Vaccination (V0.3.0)

Secure existing code without changing a single line:

from agent_airlock import vaccinate, STRICT_POLICY

# Before: Your existing LangChain tools are unprotected
vaccinate("langchain", policy=STRICT_POLICY)

# After: ALL @tool decorators now include Airlock security
# No code changes required!

Supported: LangChain, OpenAI Agents SDK, PydanticAI, CrewAI


⚡ Circuit Breaker (V0.4.0)

Prevent cascading failures with fault tolerance:

from agent_airlock import CircuitBreaker, AGGRESSIVE_BREAKER

breaker = CircuitBreaker("external_api", config=AGGRESSIVE_BREAKER)

@breaker
def call_external_api(query: str) -> dict:
    return external_service.query(query)

# After 5 failures → circuit OPENS → fast-fails for 30s
# Then HALF_OPEN → allows 1 test request → recovers or reopens

📈 OpenTelemetry Observability (V0.4.0)

Enterprise-grade monitoring:

from agent_airlock import configure_observability, observe

configure_observability(
    service_name="my-agent",
    otlp_endpoint="http://otel-collector:4317",
)

@observe(name="critical_operation")
def process_data(data: dict) -> dict:
    # Automatic span creation, metrics, and audit logging
    return transform(data)

▶️ Runnable examples

No API key, no network, no framework install — every one of these runs offline and deterministically against the installed package. Verified 2026-08-19.

Example One command What it shows
Capability handles python examples/handle_capability.py A handle in the argument stream is a bearer capability, so check it like one — the MCP 2026-07-28 shape (SEP-2567), where a server mints state from one tool and the model passes it back as an argument to another. All five verdicts against one HandleField declaration: accepted, never issued, wrong issuer, wrong scope (the cross-tenant one), expired — plus no ledger bound at all → blocked
Escalation threshold python examples/escalation_threshold.py Transfers under $500 proceed, over $500 ask a human — the scenario v0.8.74 made expressible. Dynamic policy resolution reads the amount off the request context; over the line the policy raises PolicyEscalation into the agent_airlock.oversight approver. Shows all four outcomes, including escalate with no approver registered → blocked
Basic usage python examples/basic_usage.py Ghost-argument stripping and strict validation on one decorator
Policy engine python examples/policy_example.py Allow/deny lists, time windows, rate limits, RBAC
Capability-union boundary python examples/capability_union_boundary.py Denying a capability union at grant time (v0.8.70)
Per-model-tier budgets python examples/model_tier_budget.py Pre-execution cost estimate against a tier cap
Conversation tracking python examples/conversation_tracking.py Multi-turn per-session state
Error hooks python examples/error_hooks.py on_blocked / on_rate_limit callbacks
Async tools python examples/async_tools.py The async wrapper path

Framework-specific examples (LangChain, CrewAI, PydanticAI, …) need their framework installed and are listed in the compatibility table below.

🔌 Framework Compatibility

The Golden Rule: @Airlock must be closest to the function definition.

@framework_decorator    # ← Framework sees secured function
@Airlock()             # ← Security layer (innermost)
def my_function():     # ← Your code

LangChain / LangGraph

from langchain_core.tools import tool
from agent_airlock import Airlock

@tool
@Airlock()
def search(query: str) -> str:
    """Search for information."""
    return f"Results for: {query}"

OpenAI Agents SDK

from agents import function_tool
from agent_airlock import Airlock

@function_tool
@Airlock()
def get_weather(city: str) -> str:
    """Get weather for a city."""
    return f"Weather in {city}: 22°C"

PydanticAI

from pydantic_ai import Agent
from agent_airlock import Airlock

@Airlock()
def get_stock(symbol: str) -> str:
    return f"Stock {symbol}: $150"

agent = Agent("openai:gpt-4o", tools=[get_stock])

CrewAI

from crewai.tools import tool
from agent_airlock import Airlock

@tool
@Airlock()
def search_docs(query: str) -> str:
    """Search internal docs."""
    return f"Found 5 docs for: {query}"
More frameworks: LlamaIndex, AutoGen, smolagents, Anthropic

LlamaIndex

from llama_index.core.tools import FunctionTool
from agent_airlock import Airlock

@Airlock()
def calculate(expression: str) -> int:
    return eval(expression, {"__builtins__": {}})

calc_tool = FunctionTool.from_defaults(fn=calculate)

AutoGen

from autogen import ConversableAgent
from agent_airlock import Airlock

@Airlock()
def analyze_data(dataset: str) -> str:
    return f"Analysis of {dataset}: mean=42.5"

assistant = ConversableAgent(name="analyst", llm_config={"model": "gpt-4o"})
assistant.register_for_llm()(analyze_data)

smolagents

from smolagents import tool
from agent_airlock import Airlock

@tool
@Airlock(sandbox=True)
def run_code(code: str) -> str:
    """Execute in E2B sandbox."""
    exec(code)
    return "Executed"

Anthropic (Direct API)

from agent_airlock import Airlock

@Airlock()
def get_weather(city: str) -> str:
    return f"Weather in {city}: 22°C"

# Use in tool handler
def handle_tool_call(name, inputs):
    if name == "get_weather":
        return get_weather(**inputs)  # Airlock validates

Adapter-shipped vs example-only (honest split)

Both paths use the same @Airlock() decorator placement. "Adapter-shipped" means there's a dedicated src/agent_airlock/integrations/<framework>.py module with framework-specific glue (signature preservation, tool registry rewrites, request-shape adapters). "Example-only" means the decorator is compatible out of the box — no extra adapter required.

Adapter-shipped (11): LangChain (integrations/langchain.py), LangGraph (integrations/langgraph_toolnode_compat.py), OpenAI Agents SDK (integrations/openai_guardrails.py), Anthropic Messages API (integrations/anthropic.py), Anthropic Claude Agent SDK (integrations/anthropic_claude_agent_sdk.py, v0.6.1+), smolagents (integrations/smolagents_wrapper.py), Gemini 3 Agent Mode (integrations/gemini3_tool_shape_adapter.py), GPT-5.5 (integrations/gpt5_5_tool_shape_adapter.py), PydanticAI (integrations/pydantic_ai.py, v0.7.1+), CrewAI (integrations/crewai.py, v0.7.2+), FastMCP (agent_airlock/mcp/).

Example-only (2): AutoGen, LlamaIndex — decorator-compatible without an adapter; see examples/.

Complete Examples

Framework Path Surface
LangChain adapter · example @tool, AgentExecutor
LangGraph adapter · example StateGraph, ToolNode
OpenAI Agents adapter · example Handoffs, manager pattern
Anthropic API adapter · example Direct Messages API
Claude Agent SDK adapter · doc wrap_agent(agent, policy=...)
smolagents adapter · example CodeAgent, E2B
Gemini 3 adapter function_call carrier + thought_signature redaction
GPT-5.5 adapter gpt_5_5_agent_defaults preset
FastMCP adapter · example @secure_tool decorator
PydanticAI adapter · doc · example wrap_agent(agent, policy=...) + output_validate hook
CrewAI adapter · doc · example wrap_crew(crew, policy=...) + task-level tool overrides
LlamaIndex example only ReActAgent
AutoGen example only ConversableAgent

⚡ FastMCP Integration

from fastmcp import FastMCP
from agent_airlock.mcp import secure_tool, STRICT_POLICY

mcp = FastMCP("production-server")

@secure_tool(mcp, policy=STRICT_POLICY)
def delete_user(user_id: str) -> dict:
    """One decorator: MCP registration + Airlock protection."""
    return db.users.delete(user_id)

🏆 Why Not Enterprise Vendors?

Prompt Security Pangea Agent-Airlock
Pricing Not published — quote only Not published — quote only Free forever
Integration Proxy gateway Proxy gateway One decorator
Self-Healing
E2B Sandboxing ✅ Native
Your Data Their servers Their servers Never leaves you
Source Code Closed Closed MIT Licensed

On the competitor columns. They are read off each vendor's public materials, not from testing their products, and the two vendor columns are the weakest rows on this page — treat them as a sketch of the category, not as a measurement. A previous version of this table asserted "$50K+/year" for Prompt Security with no source. Neither vendor publishes list pricing (Prompt Security is now part of SentinelOne and is quote-only), so that figure has been removed rather than re-sourced: it was not something this repo could stand behind if asked.

We're not anti-enterprise. We're anti-gatekeeping. Security for AI agents shouldn't require a procurement process.


📦 Installation

# Core (validation + policies + sanitization)
pip install agent-airlock

# With E2B sandbox support
pip install agent-airlock[sandbox]

# With FastMCP integration
pip install agent-airlock[mcp]

# Everything
pip install agent-airlock[all]
# E2B key for sandbox execution
export E2B_API_KEY="your-key-here"

🛡️ OWASP Compliance

Agent-Airlock maps to the OWASP Top 10 for Agentic Applications (2026) — the agentic-era successor to the old LLM Top 10. Coverage is reported honestly: Full means the primitive ships and blocks the class in tests; Partial means agent-airlock covers the runtime leg but something upstream (client UI, IAM, training data) is out of scope; Monitor-only means we surface the signal but do not actually prevent the risk.

Where the boundary sits. agent-airlock validates the payload of a tool call the agent is about to make — an in-process contract layer at the execution seam. A supply-chain compromise that lands before any tool call exists (a poisoned package, config file, or IDE task on disk) is a different layer: covered build / CI / IDE-time by the sibling scanner agent-audit-kit, not at airlock's runtime seam. The ASI04 row spells out which half is which.

Risk Implemented in agent-airlock Module / preset Coverage
ASI01 Agent Goal Hijack Pydantic strict validation + ghost-arg rejection + UnknownArgsMode.BLOCK validator, unknown_args, core Partial
ASI02 Tool Misuse and Exploitation Deny-by-default SecurityPolicy, RBAC, rate limits, SafePath / SafeURL, Flowise Function()/eval token ban (CVE-2025-59528), MCPwn destructive-auth check (CVE-2026-33032), Mobile MCP intent-URL guard (CVE-2026-35394), SafeURL IPv6-transition metadata SSRF (CVE-2026-48782) policy, safe_types, filesystem, network, policy_presets.flowise_cve_2025_59528_defaults, policy_presets.mcpwn_cve_2026_33032_defaults, policy_presets.mobile_mcp_intent_guard_2026_05 Full
ASI03 Identity and Privilege Abuse AgentIdentity, MCPProxyGuard token-passthrough prevention, CredentialScope, OAuth-app audit (Vercel 2026-04-19), MCP Attested Tool-Server Admission (arXiv:2605.24248), step-up scope-accumulation guard (refuse a call whose authorising scope set / issuer changed between admission and execution — SEP-2350 / SEP-2352) policy, mcp_proxy_guard, mcp_spec.oauth_audit, mcp_spec.attested_admission, mcp_spec.step_up_scope_guard, policy_presets.oauth_audit_vercel_2026_defaults, policy_presets.mcp_attested_admission_defaults, policy_presets.mcp_step_up_scope_2026_07_defaults Partial
ASI04 Agentic Supply Chain Vulnerabilities Runtime leg (covered): Ox MCP STDIO sanitizer + CVE regression suite (38 CVE/advisory regression tests in tests/cves/; the 31 CVE-numbered ones are published in the generated catalog) + session-snapshot integrity guard + spawn-time MCP config pin (CVE-2026-30615, policy_presets.mcp_config_pin), all at the tool-call seam. Out of scope for an in-process tool-call validator: on-disk supply-chain compromise that lands before any tool call exists to validate — e.g. the 2026-08-04 keyv npm worm, which poisoned agent config files and IDE task files on disk; a runtime payload validator is the wrong layer for it, and naming that boundary is the point. That leg is covered build / CI / IDE-time by agent-audit-kit. mcp_spec.stdio_guard, mcp_spec.session_guard, mcp_spec.zero_click_config_guard, policy_presets.stdio_guard_ox_defaults, policy_presets.mcp_config_pin, tests/cves/ Partial
ASI05 Unexpected Code Execution (RCE) E2B Firecracker sandbox, pluggable SandboxBackend, capability gating for PROCESS_SHELL, Flowise eval-token ban (CVE-2025-59528) sandbox, sandbox_backend, capabilities, policy_presets.flowise_cve_2025_59528_defaults Full
ASI06 Memory & Context Poisoning AirlockContext contextvars isolation, ConversationConstraints budget caps, audit logging context, conversation, sanitizer Partial
ASI07 Insecure Inter-Agent Communication A2A middleware Pydantic strict validation, method allow-lists a2a Partial
ASI08 Cascading Failures CircuitBreaker, RetryPolicy, token-bucket rate limits circuit_breaker, retry, policy Full
ASI09 Human-Agent Trust Exploitation Honeypot deception, audit-log attribution, structured fix_hints honeypot, audit_otel Partial
ASI10 Rogue Agents Audit telemetry + anomaly detector; no quarantine primitive observability, anomaly Monitor-only

MCP-specific mapping

The OWASP MCP Top 10 (2026 beta) maps to agent-airlock as follows. The OWASP_MCP_TOP_10_2026 policy preset is the policy layer — it supplies the deny-by-default controls for MCP02–MCP05 and part of MCP07; the other categories are covered by the named modules and presets alongside it, so this is not end-to-end coverage from one preset. The Status column is this repo's own assessment: Full = a dedicated control ships and is tested; Partial = a meaningful control ships but full mitigation still needs operator configuration or server-side auth that Airlock cannot supply in-process.

On the 2026-07-28-tagged guards below: MCP 2026-07-28 is the current ratified revision — /specification/latest 307-redirects to /specification/2026-07-28 and the release is prerelease: false (verified 2026-08-01). airlock accepts both 2026-07-28 (current) and 2025-11-25 (legacy) on the wire. These guards are validators and forward-compatible hardening. The first conformance run against 2026-07-28 is now published at benchmarks/mcp_conformance/RESULTS.md: airlock's transport surface passes 18/18 normative-contract cases, with three divergence probes listed as-is. It is not a full MCP server/client conformance pass, because airlock is a request validator, not a server, so the official suite's server/client scenarios have no endpoint to drive.

MCP risk Ships in agent-airlock Status
MCP01 Token Mismanagement & Secret Exposure MCPProxyGuard rejects passthrough headers and enforces audience; sanitizer masks secrets in tool output Partial
MCP02 Privilege Escalation via Scope Creep preset require_agent_id + capability gating + CredentialScope + runtime capability-union deny at grant time (CapabilityCapEngine.grant_lease denies {filesystem-read or credential} + non-allowlisted egress even when each lease is individually permitted, naming the prior lease; explicit override logged to the decision log — interop spec) Full
MCP03 Tool Poisoning ghost-arg rejection, SafePath/SafeURL, mcp_description_manifest_guard, preset install_* deny Full
MCP04 Software Supply Chain Attacks & Dependency Tampering stdio_guard_ox_defaults() (Ox 2026-04-16 advisory), preset download_plugin_* / fetch_plugin_* deny Full
MCP05 Command Injection & Execution stdio_guard shell-metachar + deny-pattern rules, preset exec_* / run_* / system_* deny, sandbox for DANGEROUS Full
MCP06 Intent Flow Subversion ToolOutputTrustGuard (untrusted_tool_output_defaults) envelopes injected-instruction tool output; @requires_human_oversight gates high-value actions Partial
MCP07 Insufficient Authentication & Authorization OAuth 2.1 + PKCE S256 helpers in mcp_spec.oauth; SEP-2468 iss mix-up validation (mcp_spec_2026_07_defaults); SEP-2243 header/body routing integrity (mcp_spec_2026_07_header_integrity_defaults); unsigned-_meta trust boundary (mcp_meta_trust_2026_07_defaults); step-up scope-accumulation guard (SEP-2350/2352, mcp_step_up_scope_2026_07_defaults); Tasks-extension lifecycle guard (SEP-1686, mcp_tasks_lifecycle_2026_07_defaults); Tasks-extension deny-by-default admission (SEP-2663, mcp_tasks_2026_07_28_defaults); elicitation-provenance guard (SEP-2260, mcp_elicitation_provenance_2026_07_defaults). Transport-level auth remains server-side. Partial
MCP08 Lack of Audit and Telemetry JSON-Lines audit log (audit), OpenTelemetry export (audit_otel), spans + metrics (observability) Full
MCP09 Shadow MCP Servers attested tool-server admission (mcp_attested_admission_defaults) + LAN unauthenticated-MCP guard (lan_unauth_mcp_guard) Partial
MCP10 Context Injection & Over-Sharing PII/secret sanitizer + workspace-scoped config + ToolOutputTrustGuard untrusted-data envelope Full

Use it directly:

from agent_airlock import Airlock
from agent_airlock.policy_presets import owasp_mcp_top_10_2026_policy

@Airlock(policy=owasp_mcp_top_10_2026_policy())
def my_mcp_tool(...):
    ...

Ox Security STDIO advisory (2026-04-16, CVE-2026-30616): see docs/cves/index.md#cve-2026-30616 and the stdio_guard_ox_defaults() preset above. agent-airlock blocks 3 of 4 Ox attack classes at the runtime seam.


🔗 Related work by the same author

Neither of these uses agent-airlock, and this section is not an adoption claim. They are listed because one of them will come up if you search, and because a previous version of this README wrongly listed both under a "Used By" heading. There are no known production users to name; when there are, they will be named here with a link to the integration.

  • FerrumDeck — a Rust enforcement plane for AI agents. It contains a module also called Airlock (fd_policy::airlock, a runtime RASP inspection layer in rust/crates/fd-policy/). It shares the name and no code with this project — different language, different repository, no dependency in either direction. If you grepped FerrumDeck for "airlock" and found 117 files, that is what you found; a grep for agent_airlock or agent-airlock there returns nothing.
  • Mnemo — an MCP-native memory database. It does not reference this library at all. The mnemo.checkpoint string that appears in the HandleField examples elsewhere in these docs is an illustrative MCP tool name, not an integration.

📊 Performance

Test count and coverage are published by the TEST-BADGE block at the top of this file, regenerated from pytest on every release via python scripts/update_test_badge.py. That block is the source of truth; this table tracks latency and surface area only.

Every latency figure below states what it measures, because the three that were previously in this table did not and disagreed by four orders of magnitude. A <50ms "validation overhead" row sat next to a benchmark table claiming p50 ~2µs; nothing in the repo measured the 50ms, so it has been deleted rather than explained.

Metric Value Scope — exactly what is timed Backed by
Policy decision, in isolation p50 0.0015 ms (~1.5µs) · p95 0.021 ms One deny-by-default policy/argument verdict on a corpus entry. No decorator round-trip, no model call, no network. python -m benchmarks.blockrateRESULTS.md
Full @Airlock call path p50 ~0.08 ms (~80µs) The whole decorator: ghost-arg check → policy → strict Pydantic validation → sanitize → audit, wrapping a real function. This is the number to budget with. python -m benchmarks.vs_gatewayRESULTS.md; independently corroborated at median 84µs by pytest tests/benchmarks/test_bench_core.py (pytest-benchmark)
Sandbox cold start ~125ms Vendor-published, not measured here — E2B's own Firecracker figure. Applies only with sandbox=True; it is the micro-VM's number, not airlock's. E2B: Firecracker vs QEMU
Framework integrations 13 compatibility table
Core dependencies 0 (Pydantic only)1 bare-install CI job

The two decision-path numbers differ by ~50× because they measure different things and now say so: 1.5µs is the verdict, 80µs is the verdict plus everything the decorator does around it. The sandbox row is an order-of-magnitude larger than both and belongs to a different component entirely — reach for sandbox=True when isolation is worth ~125ms, not for validation.

The former "Sandbox warm pool <200ms" row is gone too. sandbox.py describes <200ms as a target, no benchmark measures it, and a warm-pool figure larger than the cold-start figure it exists to avoid is incoherent on its face.


📖 Documentation

Full docs — guides, API reference, the CVE catalog, and the OWASP coverage matrix — are hosted at sattyamjjain.github.io/agent-airlock (built from docs/ on every push to main).

Resource Description
AGENTS.md v0.6.1 — repo-root entrypoint for agentic IDEs (Cursor, Claude Code, Windsurf, Mintlify)
Anthropic Claude Agent SDK adapter v0.6.1 — AnthropicClaudeAgentSDKAdapter.wrap_agent(agent, policy=...); canonical-list trio
airlock manifest enforce v0.6.1 — fail-closed CLI runtime allowlist gate against signed manifests; CI exits 0/2/3
Managed Agents Outcomes-rubric guard v0.7.4 — fail-closed gate on the Anthropic Managed Agents 2026-05-06 Outcomes rubric ID; ManagedAgentsOutcomesGuard.evaluate(provenance) + managed_agents_outcomes_2026_05_06_defaults factory; no SDK dep
Filter-Eval RCE guard (CVE-2026-25592 + CVE-2026-26030) v0.7.5 — regex detector for the Semantic-Kernel-class lambda-filter / template-expression eval RCE primitive (MSRC 2026-05-07); FilterEvalRCEGuard.evaluate(args) + semantic_kernel_filter_eval_rce_2026_25592_26030_defaults factory; framework-agnostic
OIDC publish-window guard (TanStack 2026-05-11) v0.7.6 — known-bad blast-list guard for the TanStack/Mini-Shai-Hulud npm OIDC trusted-publisher class (postmortem 2026-05-11; 42 pkgs × 84 versions); OIDCPublishWindowGuard.evaluate(args) + npm_oidc_publish_window_guard_defaults factory; pure-data preset, no runtime npm calls
MCP STDIO command-injection guard v0.7.6 — shell metachar + opt-in path-traversal denier for MCP STDIO argv vectors (HelpNetSecurity 2026-05-05); StdioCommandInjectionGuard.evaluate(args) + mcp_stdio_command_injection_preset_defaults factory; no mcp SDK dep
Eval-RCE guard (CVE-2026-44717) v0.8.0 — bare-eval()/parse_expr()/exec() invocation detector for the MCP Calculate Server class (NVD 2026-05-15); EvalRCEGuard.evaluate(args) + curated vulnerable-package denylist + parse_expr safe-form exemption + stdio_guard_eval_defaults_2026_05_15 factory
MCP Inspector exposure guard (CVE-2026-23744 runtime) v0.8.0 — Linux runtime listener-scan via stdlib /proc/net/tcp for the MCPJam Inspector public-bind class; complements v0.5.x config-time bind_address_guard; MCP_INSPECTOR_REQUIRE_AUTH=1 operator bypass
Agent SDK Credit pool budget v0.8.0 — per-month USD pool tracker for Anthropic's 2026-06-15 billing split (Zed blog 2026-05-14); AgentSDKCreditBudget.register_call(model, input_tokens, output_tokens) with 90% near-limit + 100% exhausted thresholds; packaged 2026-06 pricing fixture
OpenAPI Drift Guard (Hermes 2026-05-13) v0.8.1 — payload-shape drift detector against an operator-supplied OpenAPI 3.x spec (arXiv:2605.14312); OpenAPIDriftGuard.evaluate(operation_id, args) detects missing_required / unknown_field / type_mismatch; three modes (strict / warn / shadow); vaccinate_openapi(spec) decorator + openapi_doc_drift_guard_defaults factory; caller supplies spec dict, no PyYAML dep
MCP Calc-Server bundle preset v0.8.1 — composition factory mcp_calc_server_bundle_defaults_2026_05_15() wires v0.8.0 EvalRCEGuard + v0.7.6 StdioCommandInjectionGuard under a single preset_id (CVE-2026-44717 anchor) scoped to calc/calculate/evaluate/sympy_eval/math_eval tool-name patterns; pure config composition, no new detector module
Metis-inspired corpus block-rate regression v0.8.2 — release-gate primitive MetisInspiredCorpusBlockRateGuard runs a deterministic 25-entry exploit-shape corpus (CVE-2026-44717 + 2026-05-05 STDIO injection) through EvalRCEGuard + StdioCommandInjectionGuard; one-sided gate fires when block rate drops below baseline − 5%; NOT a reproduction of the Metis paper's POMDP attacker (arXiv:2605.10067 cited as motivation, not as prompt source); airlock corpus-bench CLI ships text/json/md reports
Corpus per-category coverage v0.8.3 — extends the v0.8.2 corpus-bench with HarnessAudit-Bench (arXiv:2605.14271) two-category taxonomy (resource_access, info_transfer); CorpusEntry.violation_category field + CategoryCount decision field; airlock corpus-bench reports per-category coverage in text/json/md; NOT a reproduction of HarnessAudit-Bench (artifacts not yet public — taxonomy adopted as schema, scoring is not)
Stainless SDK provenance classifier v0.8.3 — pure-function classify_sdk_lineage(user_agent, response_body_head) building block flags MCP servers generated by the deprecated Stainless SDK toolchain (Anthropic acquired Stainless 2026-05-13, hosted generator winding down); operator-callable from own audit hooks — NOT an automatic HTTP probe (decorator-in-process architecture, see ROADMAP §1); stainless_provenance_probe_defaults() preset is default_action=tag_only, visibility not enforcement
Human-oversight decorator v0.8.4 — @requires_human_oversight(approver=...) gates a tool function on an operator-supplied approval callable (Code-as-Harness arXiv:2605.18747 anchor); GRANT → call wrapped fn, DENYOversightDeniedError, TIMEOUTOversightTimeoutError; composes with @Airlock(...); protocol shapes + InProcessRecordedApprover testing helper; NOT a bidirectional audit-emitter RPC channel — operator owns the transport (Slack/PagerDuty/CLI), agent-airlock owns the gate + the protocol
Layer-contract receipt block v0.8.5 — opt-in LayerContract (assume/guarantee) block on signed airlock attest receipt payloads (arXiv:2605.18672 anchor); --contract derives per-guard pass_rate from the verdicts list, --assumes id1,id2 declares upstream-layer dependencies; receipt schema v1 unchanged (additive field); pass_rate is a measured statistic over the sample (not a proof) — every Guarantee carries sample_size so verifiers can weight low-N appropriately; NOT backed by a window-counter store (that infrastructure doesn't exist yet — derived from the operator-supplied verdicts list, no new abstraction)
MCP Attested Tool-Server Admission (arXiv:2605.24248) v0.8.10 — opt-in admission gate for MCP tool servers per Metere (May 2026). Host fetches a JWS-compact clearance from {server_url}/.well-known/mcp-clearance, verifies its signature against an operator-pinned trust root (Ed25519 / RSA-PSS / JWKS — never network-fetched on the hot path), and enforces a deny-by-default per-server tool allowlist parsed from the verified clearance. Flavor-gated ENFORCE (hard-deny) / WARN (log only) modes. Every decision emits a ReceiptVerdict on the guard="mcp_attested_admission" channel — reuses the existing airlock attest DSSE path, does not invent a new log. mcp_attested_admission_defaults() factory + MCPProxyGuard.audit_tool_admission() integration; signature verification gated behind pip install agent-airlock[attested].
Mobile MCP intent-URL guard (CVE-2026-35394) v0.8.8 — defensive bundle for the Mobilenexthq Mobile MCP mobile_open_url intent-injection RCE class (< 0.0.50). mobile_mcp_intent_guard_2026_05() returns a pre-configured SafeURLValidator(allowed_schemes=["http", "https"]) (blocks intent:, content:, file:, app:, data:, javascript:, vbscript:), an AirlockConfig(unknown_args=UnknownArgsMode.BLOCK), and the canonical Mobile MCP tool-name corpus (mobile_open_url, open_url, mobile_launch_url). DIFF-COMPATIBLE with the existing SafeURL type — no new validator invented. Also fixes a pre-existing block_private_ips=True no-op in SafeURLValidator (RFC1918 ranges were not actually blocked because the validator's own SafeURLValidationError raise was caught by except ValueError).
Capsule ShareLeak / PipeLeak (CVE-2026-21520) v0.8.14 — defensive bundle for the Capsule Security-disclosed indirect-prompt-injection class hitting Microsoft Copilot Studio (ShareLeak, CVE-2026-21520, CVSS 7.5 HIGH, CWE-77, patched 2026-01-15) and Salesforce Agentforce (PipeLeak, parallel pattern). Both vectors share the same architecture: untrusted form input (SharePoint form / Web-to-Lead form) is concatenated into the agent's context with no boundary, while the agent simultaneously holds outbound exfil tools (Outlook send / Salesforce email-case). capsule_indirect_injection_cve_2026_21520_defaults() composes existing primitives — default_deny=True + canonical exfil-sink denied_tools (send_email, outlook_*, create_case, share_*, export_*, post_to_*, webhook_*, ...) + reauth_on_untrusted_reinvocation=True (v0.8.6 debate-amplification guard at threshold=1) + AirlockConfig(unknown_args=UnknownArgsMode.BLOCK). Opt-in only — no new validator invented, no default-priority-chain entry. Pairs with airlock-explain --unused-scopes (v0.8.13) so operators populate the read-side allow-list from a real trace before deploying.
Flowise MCP-stdio adapter RCE (CVE-2026-40933) v0.8.16 — defensive control for the Flowise authenticated-RCE-via-MCP-stdio-adapter class (CVSS 9.9, fixed upstream in Flowise 3.1.0). Flowise ≤ 3.0.x serialises a user-defined CustomMCP command+args straight into a child-process spawn with no sandbox or argv sanitisation — importing a crafted chatflow is a one-click path to OS-level RCE. flowise_mcp_stdio_guard_2026_defaults() is a per-tool-class projection of the v0.7.6 StdioCommandInjectionGuard (no new detector invented), scoped to the Flowise CustomMCP stdio surface. Fail-closed on shell metachars (;, &&, ||, |, newline, backtick, $() in the command/args path + opt-in path-traversal outside a cwd_allowlist; check(args) raises FlowiseMcpStdioInjectionError. OWASP MCP05 Command Injection. Wired into ox_mcp_supply_chain_2026_04_defaults()corrects a prior mis-attribution where CVE-2026-40933 was recorded as a "Semantic Kernel auth-header leak".
Context7 "ContextCrush" MCP instruction injection (CVE-2026-75130) v0.8.78 — deny-by-default bundle for the Upstash Context7 served-instruction class (CVSS v3.1 9.0 Critical; NVD also records CVSS v4.0 6.4 Medium — the gap is not a disagreement about severity, it is v4.0 scoring VC:N/VI:N/VA:N with SC:H/SI:H/SA:H, i.e. Context7 itself is unharmed and the whole impact lands on the connected agent. CWE-1427, OWASP MCP03). Context7 ≤ 2.1.2 served a per-library Custom AI Instructions field through its MCP server unsanitised: an attacker registers a library in the public registry, embeds instructions in that field, and the text enters the model's context as documentation on any routine docs request. Noma Security's PoC chained three legs through the agent's own already-authorised tools — read every .env, file the contents as a GitHub issue on an attacker-owned repo, delete local folders. Nothing in that chain crosses a network boundary the agent was not already allowed to cross, which is exactly why a transport or identity layer sees three authorised calls and has nothing to object to; the failure is in the arguments. context7_contextcrush_cve_2026_75130_defaults() composes existing primitives — the v0.8.33 ToolOutputTrustGuard for the served content (check_served_content raises Context7InstructionInjectionError) + RESTRICTIVE_FILESYSTEM_POLICY so a .env path is refused as an argument even when the read tool is allowlisted + deny-by-default denied_tools for the exfil and destructive sinks. Honest limit: the shipped Agentjacking imperative set does not fire on this prose payload, so the preset supplies ContextCrush patterns through the guard's extra_imperative_patterns hook (the same idiom as StdioCommandInjectionGuard(extra_metachars=…)) — no new detector; a purely passive poisoned rule carrying no imperative is not caught, and a test pins both facts. Fixed upstream by Upstash within days.
MCP description-vs-manifest guard (mcp_description_manifest_guard) v0.8.18 — runtime consistency gate that asserts a tool's model-facing description (declared input schema + advertised capability/security boundary) matches its registered manifest before the tool is admitted, failing closed per the deny-by-default posture. Anchored on the DCIChecker study (arXiv:2606.04769), which measured Description-Code Inconsistency at 9.93% of 19,200 tool pairs across 2,214 MCP servers. DescriptionManifestGuard.evaluate(description) detects described_arg_not_in_manifest (description claims a ghost argument), undisclosed_side_effect (manifest has a side effect the description hides — the tool-poisoning direction), and overclaimed_capability (description advertises a capability absent from the manifest); three modes (strict / warn / shadow); vaccinate_description_manifest(manifests) decorator + mcp_description_manifest_guard_defaults() factory. Composes above ghost-arg stripping + Pydantic type-validation (which govern the call payload) — it does not replace them. OWASP MCP03 Tool Poisoning. Pydantic-only core, no new runtime deps.
LeRobot pickle-deserialization RCE (CVE-2026-25874) v0.8.19 — deny-by-default posture for the HuggingFace LeRobot unauthenticated-RCE class (CVSS 9.3). LeRobot's async-inference PolicyServer / robot-client pickle.loads() payloads received over an unauthenticated, non-TLS gRPC channel (SendObservations / SendPolicyInstructions / GetActions) — an unauthenticated, network-reachable attacker reaches arbitrary OS command execution. Ships a reusable UnsafeDeserializationGuard (in safe_types, next to SafePath/SafeURL) that fails closed on pickle magic bytes (0x80 PROTO), base64-encoded pickle, and pickle/marshal/shelve/dill/jsonpickle marker tokens in string args — plus an airgap pairing that refuses serialized-object (bytes) args unless the call declares an authenticated and TLS transport. Wired into SecurityPolicy.deserialization_guard and run at the @Airlock seam (Step 2.7) before the tool body; the block carries a fix_hint naming CVE-2026-25874. lerobot_cve_2026_25874_defaults() is the per-CVE projection (deny-by-name globs for *deserialize*/*pickle.loads*/torch_load/the gRPC methods + the wired content guard). Composes above ghost-arg stripping + Pydantic type-validation. Pydantic-only core, no new runtime deps.
MCP server-URL env-interpolation secret leak (CVE-2026-32625) v0.8.20 — deny-by-default guard for the LibreChat MCP-server-URL credential-disclosure class (CVSS 9.6, CWE-200, OWASP MCP01). A user-supplied MCP server connection template (URL / header / arg) carrying an env-interpolation token (${VAR}, bare $VAR, or %VAR%) is expanded server-side against the host process.env and leaks a secret (${JWT_SECRET} / ${CREDS_KEY} / ${MONGO_URI}) into the outbound request. MCPServerEnvInterpolationGuard.evaluate(config) (in mcp_spec/env_interpolation_guard.py) scans the URL/headers/args recursively and refuses any interpolation token unless its variable is on an operator-declared allowed_vars allowlist of explicitly non-secret vars (empty default = deny all). It never reads os.environ or expands anything — token-match only, so it cannot itself leak. mcp_server_env_interpolation_guard_defaults() factory + check(config) raising MCPServerEnvInterpolationError; escaped \$/$$ are not flagged. Pydantic-only core, no new runtime deps.
Codegen triple-quote / delimiter break-out RCE (CVE-2026-11393) v0.8.21 — deny-by-default guard for the AWS AgentCore CLI code-injection class (CVSS 9, CWE-94, OWASP ASI05). The CLI splices a model-/user-controlled collaborationInstruction into generated Python without neutralising triple-quote characters, so a crafted """ closes the generated string literal and injects statements that execute on agent import — RCE on the AgentCore Runtime + the importer's machine. CodegenDelimiterInjectionGuard.evaluate(args) (in mcp_spec/codegen_delimiter_guard.py) recursively scans args bound for a codegen / template / exec/eval sink and fails closed on triple-quote tokens (""" / '''), quote break-out tokens ("); / ') / " + / ']), and raw newlines — unless the field is on an operator-declared allowed_literal_fields allowlist of safe literal contexts. It never generates or executes code — token-match only. codegen_delimiter_injection_guard_defaults() factory + check(args) raising CodegenDelimiterInjectionError; composes one layer above the v0.8.0 EvalRCEGuard (which gates the sink itself). Pydantic-only core, no new runtime deps.
MCP-bridge subprocess command/args/env RCE (CVE-2026-42271, CISA KEV 2026-06-08, actively exploited) — covered + regression-tested v0.8.22 — deny-by-default guard for the LiteLLM MCP-preview-endpoint command-injection class (CVSS 8.8, CWE-77 + CWE-78, OWASP ASI05; on the CISA KEV catalog, added 2026-06-08, actively exploited). LiteLLM's POST /mcp-rest/test/connection + /mcp-rest/test/tools/list accepted the stdio-transport command / args / env fields in the request body and spawned the supplied command as a subprocess with the privileges of the proxy process — any low-privilege API key reached host command execution (unauthenticated RCE when chained with the Starlette Host-header bypass CVE-2026-48710). McpSubprocessArgInjectionGuard.evaluate(config) (in mcp_spec/subprocess_arg_guard.py) treats spawn-shaped MCP-bridge args (command/cmd/args/argv/env) as untrusted and refuses them unless the resolved program is on an operator-declared allowed_commands allowlist of safe static commands (empty default = deny all); an env carrying a code-loading var (LD_PRELOAD/PATH/PYTHONPATH/…) is refused regardless, and a config with no spawn-shaped fields passes. Never spawns anything — config inspection only. mcp_subprocess_arg_injection_guard_defaults() factory + check(config) raising McpSubprocessArgInjectionError; composes one layer above the v0.7.6 StdioCommandInjectionGuard. Regression-proofed (v0.8.38) against the actual /mcp-rest/test/connection + /mcp-rest/test/tools/list request bodies in tests/cves/test_cve_2026_42271_kev_regression.py. Pydantic-only core, no new runtime deps.
Cline cross-origin WebSocket hijack (CVE-2026-44211) v0.8.27 — deny-by-default guard for the Cline Kanban cross-origin WebSocket-hijack class (npm kanban < 2.13.0, CVSS 9.7, CWE-1385 Missing Origin Validation in WebSockets + CWE-306 Missing Authentication, OWASP ASI05). Cline runs a control WebSocket server on 127.0.0.1:3484 that accepts every upgrade without validating the Origin header; because browsers do not apply same-origin/CORS to ws://, any website the developer visits can drive the agent — leak workspace data, inject prompts into the agent terminal (RCE), or kill tasks. Binding to loopback is not a mitigation. WebSocketOriginGuard (in mcp_spec/ws_origin_guard.py) has two surfaces: audit_endpoint(host=…, origin_allowlist_enforced=…) flags a control endpoint that enforces no Origin allow-list (the misconfiguration), and check_upgrade(origin) / enforce_upgrade(origin) / wrap_handler(handler) form a runtime gate that rejects a WebSocket upgrade whose Origin is missing or outside an explicit allow-list (empty allow-list = deny all). The guard never opens a socket — descriptor / single-Origin inspection only. cline_cve_2026_44211_defaults(allowed_origins=[…]) factory + check(origin) raising WebSocketOriginHijackError. Pydantic-only core, no new runtime deps.
SSRF egress guard — alternate-encoding loopback / rebinding (CVE-2026-47390) v0.8.29 — deny-by-default egress guard for the SSRF-protection-bypass class (CWE-918, OWASP ASI02). An egress filter that checks the literal hostname string instead of the resolved IP is bypassed by encoding loopback / link-local / cloud-metadata in a form ipaddress rejects but the HTTP client connects to: 127.1, decimal 2130706433, octal 0177.0.0.1, hex 0x7f000001, ::ffff:127.0.0.1, or a public hostname whose DNS record points at 169.254.169.254 (rebinding). SSRFEgressGuard (in ssrf_egress_guard.py) reduces every target to its canonical IP(s) — decoding the alternate encodings via socket.inet_aton and resolving hostnames at check time (so a rebind to loopback is caught at connect time, not just parse time) — and fails closed on any loopback / link-local / metadata / unspecified address or an RFC1918 range not on allow_internal_hosts, with a 3-line explain audit trace (rule / resolved IP / encoding) on every denial. Composes with the v0.5.5 is_blocked_ipv6_range set (IPv4-mapped / NAT64 / 6to4 / ULA). ssrf_egress_guard_defaults(allow_internal_hosts=…) factory + check(url) raising SSRFEgressBlocked. Pydantic-only core, no new runtime deps.
MCP Origin/Host DNS-rebinding guard (CVE-2026-11624) v0.8.30 — deny-by-default Origin/Host validation for MCP HTTP/SSE/streamable transports (CWE-346 Origin Validation Error, CVSS 9.4, OWASP-MCP MCP07). Google MCP Toolbox for Databases < 0.25.0 served a local HTTP transport that did not validate the Origin or Host header, so a browser the developer visits can DNS-rebind to 127.0.0.1 and script MCP tool calls at the local server (file reads, command execution, DB access). Fixed upstream in 0.25.0 with a new --allowed-hosts flag alongside --allowed-origins, warning on the * wildcard. McpOriginHostGuard (in mcp_spec/mcp_origin_host_guard.py) validates the inbound Host (always) and Origin (when present) against explicit allowed_origins / allowed_hosts allow-lists; with none configured it falls back to loopback-only and records a startup warning, and a * wildcard allows all but also warns — mirroring the upstream fix (stdio transports have no Origin and are out of scope). check_headers(headers) / validate(headers) + a startup_warnings list; mcp_origin_host_guard_defaults(allowed_origins=…, allowed_hosts=…) factory + check(headers) raising McpOriginHostRebindingError. Pydantic-only core, no new runtime deps.
OpenClaw exec-denylist bypass at MCP loopback spawn (CVE-2026-53820) v0.8.31 — deny-by-default re-check of the resolved effective command at the MCP loopback session-spawn seam (CWE-693 Protection Mechanism Failure, CVSS 6.9, OWASP-MCP MCP05). OpenClaw < 2026.5.12 let an authenticated caller reach a denylisted command via the bundled MCP loopback spawn path because the surface command checked against the exec restriction differs from the effective command actually spawned — a name that passes the surface check resolves, via an alias / wrapper binary (env, sudo, timeout, nice, …) / shell, to a denied executable. This is a protection-mechanism-bypass at the spawn boundary, not a config-time check. LoopbackSessionSpawnGuard (in mcp_spec/loopback_spawn_guard.py) unwraps aliases + wrapper binaries to the effective program immediately before spawn and re-checks it: a resolved shell / denylisted exec is refused, and any resolved program not in allowed_commands is refused (deny-by-default). Shells are terminal (not unwrapped to their -c script), and the full unwrap is reported on decision.resolution_chain so the "effective ≠ surface" bypass is auditable. check_spawn(command) / enforce(command); openclaw_cve_2026_53820_defaults(allowed_commands=…, aliases=…) factory + check(command) raising LoopbackSessionSpawnError. Pydantic-only core, no new runtime deps.
SafeURL IPv6-transition cloud-metadata SSRF (CVE-2026-48782) v0.8.32 — extends the existing SafeURL metadata blocklist to canonicalize the host to packed ipaddress form before the comparison, closing the IPv6-transition / alternate-encoding bypass class (CWE-918, OWASP ASI02). pydantic-ai's metadata blocklist compared the hostname string, so encoding 169.254.169.254 as IPv4-mapped IPv6 (::ffff:169.254.169.254), IPv4-compatible (::169.254.169.254), 6to4 (2002:a9fe:a9fe::), Teredo, or a decimal/octal/hex integer (2852039166 / 0xa9fea9fe) slipped past it while the HTTP client still reached IMDS — exposing cloud IAM credentials. This closes the IPv6-transition gap left by the upstream fix of CVE-2026-46678. The new metadata_ip_candidates(host) helper collapses every encoding (incl. the embedded IPv4 of each transition form) to its packed address and compares against a canonical metadata-IP set (AWS/GCP/Azure 169.254.169.254/.253, AWS IPv6 fd00:ec2::254, Alibaba 100.100.100.200); legitimate public IPv6 hosts are unaffected. Additive only — the public SafeURL signature is unchanged and the check is on by default under block_metadata_urls=True, so every SafeURLValidator (and the SafeURL Pydantic type) inherits it. Pydantic-only core, no new runtime deps.
SafeURL DNS-rebinding SSRF (GHSA-mrvx-jmjw-vggc) v0.8.34 — makes the SafeURL egress guard DNS-rebinding-safe (OWASP Agentic ASI02; SSRF is not a standalone OWASP MCP Top-10 category). The SearXNG MCP Server (High, disclosed 2026-06-19) validated only the syntactic hostname string against a private-IP blocklist without resolving DNS — so an attacker domain resolving to a private/loopback IP (wildcard DNS like nip.io, or a custom record) passed the string check and the server then read arbitrary internal services. SafeURLValidator gains a dns_rebinding_guard flag (additive, default off for back-compat; ON in the dns_rebinding_safe_url_defaults() OWASP-MCP preset): after the syntactic allowlist it resolves the host at call time and re-validates every resolved A/AAAA address against the private / loopback / link-local / metadata blocklist (169.254.169.254, ::1, 127/8, 10/8, 172.16/12, 192.168/16, fc00::/7); unresolvable hosts fail closed. guard.resolve_and_pin(url) returns the validated URL + the pinned IP to connect to (resolve once, connect to that IP) so a rebind cannot flip the record between check and connect (TOCTOU). Composes with the v0.8.32 metadata-canonicalization (so a resolved ::ffff:169.254.169.254 is caught too). Pydantic-only core, no new runtime deps.
Tool-output trust-boundary guard — Agentjacking class v0.8.33 — opt-in, per-tool defense on the output side of the tool seam (OWASP-MCP MCP06 Intent Flow Subversion — injected tool-result instructions that hijack the agent's intended flow). Every other Airlock layer guards the input (arguments into a tool); this guards the output — a tool/MCP result about to flow back into the agent's context. It flags content shaped like injected instructions (override directives like "ignore previous instructions", imperative command directives, fenced shell commands — the Agentjacking "resolution steps" shape — and tool-call-shaped JSON smuggled inside diagnostic/error fields), wraps the result in a clearly-delimited untrusted-data envelope so the model treats it as data not instruction, and emits a structured event (bridged to OTel by the audit exporter). Default STRICT = flag + envelope; never executes, never silently drops. Reference cases: Agentjacking (Tenet Security, disclosed 2026-06-12; Sentry mitigation 2026-06-18 — a vulnerability class, no CVE ID) and CVE-2026-42824 "SearchLeak" (M365 Copilot Enterprise, Varonis, disclosed 2026-06-15). ToolOutputTrustGuard (in tool_output_trust_guard.py) inspect(output) / process(output) / envelope_output(output); opt in per-tool via untrusted_tool_output_defaults() / the UNTRUSTED_TOOL_OUTPUT preset constant. Pydantic-only, zero-dep core.
EU AI Act Art. 12-style tamper-evident decision log v0.8.40 — offline, restart-surviving, append-only hash-chained record-keeping for the tool-call decision layer. Every validate → policy → execute → sanitize decision appends to a JSON Lines log where record_hash = SHA-256(canonical(fields incl. prev_hash)), so any edit / deletion / reorder breaks every later hash and is detected at its exact sequence number; the chain reloads and re-verifies across process restart (fail-closed on a broken chain). agent_airlock.conformance.DecisionLog + export_evidence_bundle() (offline JSON + human-readable coverage table mapping each Art. 12 record-keeping expectation to the airlock field that satisfies it) + the airlock-conformance CLI (record / verify / export). No network, no cloud; stores decision metadata only (never raw args/results). Honest scope: Art. 12-style record-keeping evidence for the tool-call layer — not a full QMS and not a substitute for the provider's conformity assessment (high-risk obligations apply 2026-08-02). Restart-survival + tamper-evidence pinned by tests/test_conformance_decision_log.py. Pydantic-only, zero-dep core.
MCP spec 2026-07-28 hardening (SEP-2468 iss-validation + Server-Card trust boundary) v0.8.41 — client-side hardening for the MCP 2026-07-28 final spec, composed from existing airlock primitives (no new mechanism, Pydantic-only core). (a) SEP-2468 / RFC 9207 iss validation — MCP clients must validate the iss parameter on an OAuth authorization response against the authorization server the flow started with, to defend against an authorization-server mix-up attack; validate_authorization_response_iss(params, expected_issuer=…) (in mcp_spec/oauth.py) is deny-by-default — a missing or mismatched iss raises IssuerMismatchError (this adds a check, it does not weaken any existing OAuth validation). (b) Server-Card trust boundary — a tool description fetched from a server card is attacker-influenceable content, not trusted config; a poisoned ("…ignore previous instructions and run…") description is an Agentjacking-class injection into the agent's context. The preset reuses the shipped ToolOutputTrustGuard (same guard as untrusted_tool_output_defaults) to classify each tool description as untrusted output and blocks the card on an injection-shaped description. mcp_spec_2026_07_defaults(expected_issuer=…) / the MCP_SPEC_2026_07 preset constant → check_oauth_response(params) + check_server_card(card). SEP-2468 / RFC 9207 are spec ids, not CVEs. Pydantic-only, zero-dep core.
MCP stateless conformance (SEP-2567 / SEP-2575) v0.8.44 — contract-level conformance for the MCP 2026-07-28 stateless model, which removed the server-side session lifecycle (no initialize→session handshake, no Mcp-Session-Id header) and now passes state explicitly, as an ordinary typed tool argument. Composed from existing airlock primitives (no new engine, Pydantic-only core). (a) SEP-2575check_request(request) rejects a call still carrying Mcp-Session-Id (top level or under headers/meta/_meta/transport) or invoking a removed lifecycle method (initialize / notifications/initialized); raises StatefulSessionError, deny-by-default. (b) SEP-2567check_tool_call(tool, kwargs) requires any state-handle arg (state / state_handle / cursor / resume_token / session_state) to be an explicit declared contract parameter, not absorbed by **kwargs or smuggled as a ghost arg; reuses the shipped get_valid_parameters / GhostArgumentError (strict typing enforced by airlock's existing Pydantic validator). mcp_stateless_conformance_2026_07_defaults() / the MCP_STATELESS_CONFORMANCE_2026_07 constant. SEP-2567 / SEP-2575 are spec ids, not CVEs. Opt-in, Pydantic-only, zero-dep core.
MCP header integrity (SEP-2243) v0.8.45 — contract-level conformance for the MCP 2026-07-28 SEP-2243 routing headers. The Streamable HTTP transport mirrors selected body fields into Mcp-Method / Mcp-Name headers so intermediaries can route without parsing the body; the ratified header table requires Mcp-Method on all requests and Mcp-Name only on the name-bearing methods (tools/call, resources/read, prompts/get, source params.name / params.uri), and mandates that "Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body". Because the edge routes / rate-limits / authorizes on those headers while the server executes the body, a header/body mismatch is a confused-deputy vector (one operation routed past the gateway, another executed). check_request(request) requires Mcp-Method on every request and Mcp-Name on the name-bearing methods, and that both match the body's method (request["method"]) and operation name (request["params"]["name"] / params.uri); fail-closed (deny) on any missing required header or disagreement, raising HeaderBodyMismatchError — which carries a structured audit_event mapping (event / reason / header_method / body_method / …) for the audit log. mcp_spec_2026_07_header_integrity_defaults() / the MCP_SPEC_2026_07_HEADER_INTEGRITY constant. Composed from existing primitives (stdlib mapping traversal, no new engine). SEP-2243 is a spec id, not a CVE. Opt-in, Pydantic-only, zero-dep core.
MCP stateless handle-channel trust (SEP-2567 + SEP-2243) v0.8.69 — deny-by-default for the channel the stateless rewrite created. 2026-07-28 removed the session (SEP-2575) and put cross-call state on the tool-argument channel as a server-minted handle (SEP-2567), while headers can now be sourced from tool parameters via x-mcp-header (SEP-2243) — so state and headers share the injection-controlled channel. mcp_spec_2026_07_28_handle_trust_defaults() composes existing primitives (no new engine): (1) check_tool_call(tool, kwargs) — a server-minted handle must be an explicit declared parameter, not **kwargs/ghost (reuses validate_state_handle_declaredGhostArgumentError); (2) check_headers(names) — reject an x-mcp-header / Mcp-Param-* header that would set or override a policy-decision header, from an explicitly enumerated set (RESERVED_DECISION_HEADERS: MCP-Protocol-Version / Mcp-Method / Mcp-Name / Authorization / Mcp-Session-Id / Origin / Host), not a prefix; (3) check_handle(handle, *, minted)deny a handle not minted in the current policy scope. Raises HandleTrustError with a structured audit_event. Honest limit: a contract layer cannot verify a server-minted handle should have been minted — see the trust-boundary writeup. SEP-2567 / SEP-2243 are spec ids, not CVEs. Opt-in, Pydantic-only, zero-dep core.
MCP _meta trust boundary (2026-07-28) v0.8.50 — treats the MCP 2026-07-28 _meta block as untrusted client input. The final spec moves the client's protocol version, client info, and capabilities into an unsigned _meta object on every request, so any stateless server instance can answer any call — but a server that keys an authorization or routing decision off those fields is trusting attacker-controlled data. Akamai (2026-06-25, The New MCP Specification: What Security Teams Must Prepare For): "Because these fields lack cryptographic signing, if a server thoughtlessly trusts this metadata for routing or authorization decisions, a single malicious request can instantly lead to privilege escalation or cross-tenant data access." validate_meta_trust(request, *, pinned=…) is deny-by-default: with a MetaPin (the out-of-band entitlement — an OAuth claim / mTLS identity / deployment config) any disagreeing _meta value fails closed; with no pin, any _meta key asserting a capability / role / scope / permission that would broaden the call is refused (the operator opts into a narrower set); and shape discipline always runs (_meta must be a mapping, identity/role values must be scalar, keys colliding under case/unicode normalization are refused, total size is capped). Benign _meta (traceparent / progressToken / ordinary annotations) passes untouched. Raises MetaTrustError carrying a structured audit_event (same shape as HeaderBodyMismatchError). mcp_meta_trust_2026_07_defaults() / the MCP_META_TRUST_2026_07 constant. _meta trust is NOT a SEP id and NOT a CVE — it is a trust-boundary reading of the 2026-07-28 spec. Composed from existing primitives (stdlib mapping traversal, no new engine). Opt-in, Pydantic-only, zero-dep core.
MCP step-up scope-accumulation guard (SEP-2350 / SEP-2352) v0.8.52 — refuses a tool call whose authorising scope set changed after admission (OWASP-Agentic ASI03). Between a call being admitted (authorised by a scope set from an authorization server) and executing, an agent can complete an OAuth step-up that grants broader scopes; a server that re-reads the live token then runs the already-admitted call under the newly-broadened authority — confused-deputy scope accumulation. capture_admission_snapshot(tool, *, scopes, issuer) snapshots the exact authorising scope set at admission, bound to the credential's issuing authorization server (RFC 9207 iss); verify_scope_unchanged(snapshot, *, live_scopes, live_issuer) re-checks at execution and is deny-by-default: broadening (the primary attack shape) and narrowing are refused, and a live issuer that differs from the admitted one is always refused (RFC 9207 / SEP-2468) — a scope from a different authorization server can never satisfy another's snapshot. Explicit allow_scope_change opt-out (scope-set only, never the issuer binding); the opt-out path still emits the decision through the shipped OTel track_event hook. Raises ScopeAccumulationError with a structured audit_event (admitted vs live scope set, the delta, and each issuer). mcp_step_up_scope_2026_07_defaults() / the MCP_STEP_UP_SCOPE_2026_07 constant. SEP-2350 / SEP-2352 are spec ids, not CVEs. Composed from existing primitives (stdlib set operations + the shipped observability hook), in-process (not a proxy). Opt-in preset, Pydantic-only, zero-dep core.
MCP Tasks-extension lifecycle guard (SEP-1686) v0.8.53 — guards the MCP 2026-07-28 Tasks extension (call-now / fetch-later: a request returns a task handle, polled via tasks/get / tasks/update / tasks/cancel). Extends the SEP-2350/2352 step-up guard rather than duplicating it. Deny-by-default: (a) admit_task(registry, task_id, *, scopes, issuer, principal, expires_at=…) binds the handle at admission to the authorising scope set + principal; (b) check_task_op(registry, method, task_id, *, live_scopes, live_issuer, principal) refuses a tasks/* op if the caller's current scope set no longer covers the task's admission scope (reuses verify_scope_unchanged — scope broadening/narrowing and a different issuer are refused) or the authorizing token has expired; (c) tasks/list is spec-removed, so any attempt to enumerate — or operate on — a task the caller never received a handle for (unknown handle / cross-principal) is refused (no cross-task enumeration). Raises TaskLifecycleError with a structured audit_event; every decision flows through the shipped OTel track_event hook. mcp_tasks_lifecycle_2026_07_defaults() / the MCP_TASKS_LIFECYCLE_2026_07 constant. SEP-1686 is a spec id, not a CVE. In-process (not a proxy), Pydantic-only, zero-dep core.
MCP Tasks deny-by-default admission (SEP-2663) v0.8.54 — SEP-2663 finalises Tasks as an official extension in the MCP 2026-07-28 spec, and a server may return a task handle unsolicited. This is the admission-control layer above the SEP-1686 lifecycle guard: a tasks/* op is denied unless the client actually advertised the Tasks extension (admit_task / check_task_op refuse tasks_capability_not_advertised when the capability — {"tasks": {}} or {"experimental": {"tasks": {}}} — is absent), outstanding tasks are capped per principal (max_outstanding_tasks, refuse task_quota_exceeded) and expire on a TTL (task_ttl_seconds, refuse task_ttl_exceeded, sweeping stale handles to free a quota slot) — shutting the Akamai hit-and-run task-DoS shape. The handle→principal/scope binding and unsolicited-/foreign-handle rejection are reused verbatim from the SEP-1686 guard (TasksAdmissionError subclasses TaskLifecycleError, so one except catches every Tasks refusal). Every decision flows through the shipped OTel track_event hook. mcp_tasks_2026_07_28_defaults() / the MCP_TASKS_2026_07_28 constant. SEP-2663 is a spec id, not a CVE. Composed from existing primitives, in-process (not a proxy), Pydantic-only, zero-dep core.
MCP elicitation-provenance guard (SEP-2260) v0.8.54 — SEP-2260 makes any unsolicited server→client request invalid: a server may only raise an elicitation / sampling prompt within an active client-initiated request window. A server that pushes an elicitation with no client request in flight is a clean injection vector — the host renders the prompt as if the user's own system asked for it. This is the provenance (the when) axis, complementary to the shipped content classifier mcp_elicitation_guard_2026_04 (the what). It tracks the client requests currently in flight (a RequestWindow) and is deny-by-default: check_elicitation_solicited(window, *, request_id=…, server_origin=…) refuses unsolicited_elicitation when no window is open, and foreign_request_window when the referenced request id is not in flight. Operators mark the window with the client_request_window(window, request_id) context manager alongside @observe — no new engine, no new dependency. Raises ElicitationProvenanceError with a structured audit_event; every decision flows through the shipped OTel track_event hook. mcp_elicitation_provenance_2026_07_defaults() / the MCP_ELICITATION_PROVENANCE_2026_07 constant. SEP-2260 is a spec id, not a CVE. In-process (not a proxy), Pydantic-only, zero-dep core.
airlock scan-tools — static contract / type-checker v0.8.42 — a static type-checker for MCP tool declarations, distinct from the runtime @Airlock seam and from content-signature tool-poisoning scanners (MCP-Scan, eSentire MCP-Scanner). Given a set of tool defs (.json / dir / mcp.json-style config), it checks each tool's declared contract against a least-privilege SecurityPolicy and grades it pass / warn / fail: over-broad argument surfaces (additionalProperties not false on a destructive tool → ghost-arg vector), missing type constraints on sensitive args, capability caps that exceed the policy (via the shipped CapabilityPolicy), and server-card tool descriptions that widen the trust boundary (reuses the mcp_spec_2026_07 Server-Card / SEP-2468 guard). airlock-scan-tools ./tools/ --policy strict (exit 0/1/2 for CI); --output json / --output sarif (SARIF 2.1.0 for the GitHub Security tab). Coverage measured against MCPTox-derived fixtures (python -m benchmarks.scantools_mcptox): 69.2% static contract-checking coverage · 100% precision, reported as-is — explicitly not MCPTox's model-in-the-loop Attack Success Rate. agent_airlock.scan, Pydantic-only, zero-dep core.
Examples 13 framework integrations (11 adapter-shipped + 2 example-only) with copy-paste code
Security Guide Production deployment checklist
API Reference Every function, every parameter
Egress Bench CVE fixture walker — every payload previously blocked stays blocked
Detour hijacking arXiv:2608.12273 — the task completes and the cost is the payload; what the per-call record does and does not show
freeze() dropped security fields v0.5.7–v0.8.73 — SecurityPolicy.freeze() silently reverted stdio_mode, sequence_guard, action_contradiction_gate, deserialization_guard and trace_redaction to their defaults, so freezing a hardened policy relaxed it. Fixed in v0.8.74
OX MCP Supply-Chain preset Umbrella for the 2026-04-20 OX dossier (10 CVEs)
Elicitation guard (mcp_elicitation_guard_2026_04) v0.6.0 — runtime mitigation for the MCP tool/elicitation round-trip (spec PR #1487, draft 2026-04-r1); blocks credential-request and policy-override classes
Config-path guard (CVE-2026-31402) v0.6.0 — Claude Desktop MCP-server-registration path-traversal mitigation (CVSS 8.8)
Gemini 3 Agent Mode adapter v0.6.0 — function_call carrier normalisation + thought_signature redaction; pinned SUPPORTED_VERSIONS set
OAuth state entropy guard v0.6.0 — base64/hex/JSON decode + prompt-injection scan on the OAuth state parameter (BlackHat Asia 2026 vector)
airlock console v0.6.0 — three-pane Textual TUI with live verdict stream + replay-on-edit; gated behind airlock[console] extra
airlock attest receipt v0.6.0 — Sigstore-compatible signed agent-run receipts; emit + verify subcommands
policy_bundle.lock v0.6.0 — hash-pinned preset bundles with Cargo.lock semantics; airlock pack lock + airlock replay --bundle-lock
airlock studio v0.6.0 — local stdlib HTTP rehearsal sandbox; paste-a-transcript verdicts + diff between runs
smolagents wrapper v0.6.0 — wrap_agent(agent, policy_bundle) for HuggingFace smolagents 1.18+ (4th first-class framework)
STDIO meta-guard (mcp_stdio_meta_cve_2026_04) v0.5.9 — bundles every airlock STDIO defence into one chain; recommended default for any MCP server registered after 2026-04-26
LangGraph 1.0.11 ToolNode compat shim v0.5.9 — silent unwrap survives the prebuilt 1.0.11 list-vs-dict shape break
GPT-5.5 ("Spud") agent defaults + tool-shape adapter v0.5.9 — caps fan-out at 8 / context at 900k / per-call egress at 512 KB
Capability caps (agent_capability_default_caps) v0.5.9 — programmatic caps for SIGN_CONTRACT / DELEGATE_TO_AGENT / INVOKE_TOOL / WRITE_FILE / NETWORK_EGRESS
OWASP Agentic 2026-Q1 coverage matrix v0.5.9 — 10/10 mapping risk_id → guard + preset + test, CI gate fails on stale entries
Short-form-video corpus (wild-2026-04/short_form_video) v0.5.9 — 5 transcript / on-screen / RTL PoCs; airlock replay --namespace short_form_video
airlock graph serve v0.5.9 — local web UI of the live agent → tool → MCP-server topology with verdict overlay
airlock policy compile / explain v0.5.9 — natural-language policy authoring with hash-pinned prompt + deterministic cache
airlock kill-switch v0.5.9 — HMAC-signed cluster-wide freeze with 2-of-3 quorum reset
Comment-and-Control PR-metadata guard v0.5.8 — neutralises CVSS 9.4 cross-vendor PR-title prompt injection
airlock pack v0.5.8 — signed policy bundles; airlock pack install claude-code-ci@2026.04
airlock baseline v0.5.8 — per-agent 7-day rolling profile + drift score
airlock attest v0.5.8 — DSSE provenance per verdict
Cloudflare Mesh compat v0.5.8 — runs alongside Mesh; de-duplicates overlapping policies
Manifest-only STDIO mode v0.5.7 — signed-manifest registry; argv never originates from runtime input
STDIO-taint CI gate v0.5.7 — AST taint analyzer; flags remote→Popen flows at PR time
Declarative preset YAML v0.5.7 — composite presets via stdlib-only YAML parser
CVE-2026-30615 Windsurf zero-click v0.5.7 — diff-on-demand mcp.json auto-load guard; v0.8.23 adds mcp_config_pin — a spawn-time {name, command, args, env-keys} fingerprint pin (McpConfigPinSet.check()) that fails closed (raises, never warns) on an injected (unpinned) or mutated STDIO server even when the injection never touched a watched config file; emits on the structlog + JSON-Lines audit channels
CVE-2026-6980 GitPilot-MCP v0.5.7 — repo_path injection (vendor unresponsive)
DockerBackend v0.5.1 hardening + known gaps

Regulatory engagement


👤 About

Built by Sattyam Jain — AI infrastructure engineer.

This started as an internal tool after watching an agent hallucinate its way through a production database. Now it's yours.


🤝 Contributing

We review every PR within 48 hours.

git clone https://github.com/sattyamjjain/agent-airlock
cd agent-airlock
pip install -e ".[dev]"
pytest tests/ -v

💖 Support

If Agent-Airlock saved your production database:

  • Star this repo — Helps others discover it
  • 🐛 Report bugsOpen an issue
  • 📣 Spread the word — Tweet, blog, share

⭐ Star History

Star History Chart


Built with 🛡️ by Sattyam Jain

Making AI agents safe, one decorator at a time.

GitHub Twitter


Sources: This README follows best practices from awesome-readme, Best-README-Template, and the GitHub Blog.

  1. "Core dependencies: 0" counts third-party runtime installs beyond the Pydantic foundation. The one caveat: on Python 3.10 a single environment-marked wheel, tomli, backports the standard library's tomllib TOML parser (which is stdlib on 3.11+), so it is not a third-party dependency on any modern Python. structlog was a genuine unconditional core dependency through v0.8.58; as of v0.8.59 it moved to the optional [logging] extra, with a stdlib-logging fallback in the core, so a bare pip install agent-airlock pulls Pydantic only. 2

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_airlock-0.8.80.tar.gz (721.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_airlock-0.8.80-py3-none-any.whl (797.5 kB view details)

Uploaded Python 3

File details

Details for the file agent_airlock-0.8.80.tar.gz.

File metadata

  • Download URL: agent_airlock-0.8.80.tar.gz
  • Upload date:
  • Size: 721.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for agent_airlock-0.8.80.tar.gz
Algorithm Hash digest
SHA256 6a12b29adb477f87f74c9876fc7e9acb19be1b3b6165e365fc4fcb023acedd34
MD5 ef9cb028485491227982248af2dfa69e
BLAKE2b-256 ed6d7f341cecfec59cc8ecbfe346248fd6331763805545b1fe553876a3c91525

See more details on using hashes here.

File details

Details for the file agent_airlock-0.8.80-py3-none-any.whl.

File metadata

  • Download URL: agent_airlock-0.8.80-py3-none-any.whl
  • Upload date:
  • Size: 797.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for agent_airlock-0.8.80-py3-none-any.whl
Algorithm Hash digest
SHA256 0a817ecea7bd0e6f34d8618cda4c312028edfda328b2a313271f0ce0f4f471aa
MD5 0a76c688472319cd5ed7ae2d8cbe2332
BLAKE2b-256 c1796f60f551d015ba604e2e19f4dd61e42d632ce2ae4bd4bd8e276b58e3299c

See more details on using hashes here.

Release history Release notifications | RSS feed

0.8.81

2 files

This release

0.8.80 This release

2 files

0.8.79

2 files

0.8.78

2 files

0.8.77

2 files

0.8.76

2 files

0.8.75

2 files

0.8.74

2 files

0.8.73

2 files

0.8.72

2 files

0.8.71

2 files

0.8.70

2 files

0.8.69

2 files

0.8.68

2 files

0.8.67

2 files

0.8.66

2 files

0.8.65

2 files

0.8.64

2 files

0.8.63

2 files

0.8.62

2 files

0.8.61

2 files

0.8.60

2 files

0.8.59

2 files

0.8.58

2 files

0.8.57

2 files

0.8.56

2 files

0.8.55

2 files

0.8.54

2 files

0.8.53

2 files

0.8.52

2 files

0.8.51

2 files

0.8.50

2 files

0.8.49

2 files

0.8.48

2 files

0.8.47

2 files

0.8.46

2 files

0.8.45

2 files

0.8.44

2 files

0.8.43

2 files

0.8.42

2 files

0.8.41

2 files

0.8.40

2 files

0.8.39

2 files

0.8.38

2 files

0.8.37

2 files

0.8.36

2 files

0.8.35

2 files

0.8.34

2 files

0.8.33

2 files

0.8.32

2 files

0.8.31

2 files

0.8.30

2 files

0.8.29

2 files

0.8.28

2 files

0.8.27

2 files

0.8.26

2 files

0.8.25

2 files

0.8.24

2 files

0.8.23

2 files

0.8.22

2 files

0.8.21

2 files

0.8.20

2 files

0.8.19

2 files

0.8.18

2 files

0.8.17

2 files

0.8.16

2 files

0.8.15

2 files

0.8.14

2 files

0.8.13

2 files

0.8.12

2 files

0.8.11

2 files

0.8.10

2 files

0.8.9

2 files

0.8.8

2 files

0.8.7

2 files

0.8.6

2 files

0.8.5

2 files

0.8.4

2 files

0.8.3

2 files

0.8.2

2 files

0.8.1

2 files

0.8.0

2 files

0.7.6

2 files

0.7.5

2 files

0.7.4

2 files

0.7.3

2 files

0.7.2

2 files

0.7.1

2 files

0.7.0

2 files

0.6.1

2 files

0.6.0

2 files

0.5.9

2 files

0.5.8

2 files

0.5.7.1

2 files

0.5.7

2 files

0.5.6.1

2 files

0.5.6

2 files

0.5.5

2 files

0.5.4

2 files

0.5.3

2 files

0.5.2

2 files

0.5.1

2 files

0.5.0

2 files

0.4.1

2 files

0.4.0

2 files

0.2.0

2 files

0.1.5

2 files

0.1.3

2 files

0.1.2

2 files

0.1.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page