A type-checker and contract layer for AI agent tool calls, deny-by-default, in-process, Pydantic-only. Strict argument validation, ghost-argument stripping, and self-healing retries for MCP servers and agent frameworks.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
A deny-by-default contract layer for AI agent tool calls
Strict argument validation, ghost-argument stripping, and self-healing retries — one decorator, any agent or MCP server. Pydantic-only core.
Test suite: 4,512 tests · Coverage: 87.44% · v0.10.6
Coverage runs 4,500 of 4,512; the 12 excluded are 8 benchmark tests (not correctness tests) and 4 docker-marked (need a daemon — they run in CI's docker-sandbox job, against the image built from the Dockerfile at the repo root).
An LLM decides which tool to call and what to pass it. Nothing checks the arguments before
they reach your code.檢視完整 README ↗Strict argument validation, ghost-argument stripping, and self-healing retries — one decorator, any agent or MCP server. Pydantic-only core.
Test suite: 9,440 tests · Coverage: 91.35% · v0.10.31
Coverage runs 9,421 of 9,440; the 19 excluded are 8 benchmark tests (not correctness tests), 5 docker-marked (need a daemon — they run in CI's docker-sandbox job, against the image built from the Dockerfile at the repo root) and 6 live API contract tests (need an Anthropic API key and spend API credit; make test-live).
Quickstart · Benchmarks · Frameworks · Docs · How numbers are reported
An LLM decides which tool to call and what to pass it. Nothing checks the arguments before they reach your code.
# Your tool expects an int. The model sends a string, and invents a parameter.
transfer_funds(amount="500", to="alice", force=True)
# ^^^^^ ^^^^^^^^^^
# wrong type never in your signature
Type coercion turns "500" into 500 and moves on. The invented force=True lands in
**kwargs and silently changes behaviour. Neither is a model failure you can prompt away —
it is a missing contract at the call boundary.
pip install "agent-airlock>=0.10.3"
from agent_airlock import Airlock
@Airlock()
def transfer_funds(amount: int, to: str) -> str:
return f"Sent ${amount} to {to}"
That is the whole integration. Now:
| The model sends | What happens |
|---|---|
amount="500" | Blocked — Pydantic V2 strict mode, no coercion. Returns a structured error carrying fix_hints the model can retry against |
force=True | Stripped — a parameter that is not in your signature never reaches your function |
amount=500, to="alice" | Executes |
A blocked call returns an AirlockResponse rather than raising, so the model gets a
refusal it can act on instead of a traceback it cannot.
The decorator runs a fixed sequence of gates before your function and sanitises after it. Everything except validation is off by default.
| Layer | What it covers |
|---|---|
| Validate | Pydantic V2 strict mode · ghost-argument stripping · self-healing fix_hints |
| Authorize | RBAC · token-bucket rate limits · time windows · per-model-tier cost budgets · capability gating |
| Isolate | Filesystem path validation · network egress control · sandboxed execution (sandbox=True runs on E2B; Docker, Modal and local backends are called directly) |
| Sanitize | PII and secret masking, including opt-in Indic PII · output truncation · JSON-Lines audit log |
| MCP guards | Per-CVE guards for stdio injection, OAuth, DNS rebinding, SSRF, eval-RCE, code-sandbox host-bridge, WebSocket origin and task lifecycle — tracking MCP spec revision 2026-07-28 |
Two checks that run before any argument is read, both inert until you set them:
AirlockConfig(expected_token_audience="https://mcp.example.test")
checks the aud claim of the OAuth token behind every guarded call, read from the claims
your server verified and put on AirlockContext(metadata={"access_token_claims": ...}),
never from tool arguments. It warns by default; token_audience_mode="deny" refuses a
token issued to another application, the bug in
CVE-2026-115413.startup_hook_check="warn" (or "deny") lists the executable .pth
lines and sitecustomize / usercustomize of the running interpreter, once per process;
deny refuses every call its config guards while one is not on startup_hook_allowlist. Those
hooks ran before your code did, so this reports them; it cannot stop them.Details: Token audience and startup hooks.
Nine MCP server CVEs published on 27 and 28 October 2026 share one shape: a string argument
of an ordinary tool reaches a shell or a package installer on the server.
CVE-2026-117558 (ai-sdlc, repo),
CVE-2026-117559 (DroidMind),
CVE-2026-117560 (ATXP),
CVE-2026-117561 (sf-mcp),
CVE-2026-117567 (esp-mcp, target, port, project_path),
CVE-2026-117568 (mcp-ffmpeg),
CVE-2026-117569 (Kali-GPT),
CVE-2026-117573 (hagent, args) and
CVE-2026-59176 (functype-mcp-server,
version into pnpm add). ToolArgSinkGuard, off by default, reads those arguments on
the call path, before the call reaches a server that builds a command line:
from agent_airlock import Airlock, SecurityPolicy, ToolArgSinkGuard
guard = ToolArgSinkGuard(shell_args={"repo"}, installer_args={"version"})
@Airlock(policy=SecurityPolicy(tool_arg_sink_guard=guard))
def check_issue(repo: str, version: str = "1.4.4") -> str:
return f"checked {repo}"
repo="x; id" is refused (block_reason "tool_arg_sink"), and so is a version that is
not a bare PEP 440 or npm semver version: a URL, a path, file: or npm:, pkg[extra].
screen_all_str_args=True screens every argument except free_text_args;
policy_presets.tool_arg_sink_defaults() does that in warn mode first. Details:
Shell and installer arguments.
dispatch_args covers an argument that names a function the tool then calls. Agno's
PandasTools passed read_pickle to getattr(pandas, ...) that way
(CVE-2026-119050): no shell syntax for the
check above to see. ToolArgSinkGuard(dispatch_args={"operation": ("head", "describe")})
admits those names only; an empty allowlist refuses private names, *pickle*, read_*,
to_*, eval, exec and their like. Preset: policy_presets.reflection_sink_defaults().
Details: Dispatch arguments.
Full reference on the documentation site.
Every row is one command you can run. Dates are when the number was last actually produced,
not when the row was last edited — check_benchmark_freshness.py
fails the release if any row drifts past 30 days.
| Benchmark | Result | Reproduce |
|---|---|---|
| Cross-tool block-rate · 210 tool calls | 100% blocked (106/106) · 0% false-positive (0/104) · p50 ~2µs1 | python -m benchmarks.blockrate · re-run 2026-10-03 · results |
sandbox=True dispatch · contract parity | 4/4 annotated-contract probes refused on the sandbox path · 204/204 verdicts agree with the local path2 | python -m benchmarks.blockrate · re-run 2026-10-03 · results |
| Least-privilege · ToolPrivBench, 100 scenarios | 100% over-privileged blocked · 100% low-privileged allowed · OPUR 100% → 0% | python -m benchmarks.toolprivbench · re-run 2026-10-03 · results |
| Indirect prompt injection · AgentDojo, all 4 suites | 86.0% of injection→target tool-calls blocked (deterministic bound)3 | python -m benchmarks.agentdojo.run · re-run 2026-10-12 · results |
| Tool poisoning · MCPTox, 1,348 cases | Direct execution of the poisoned tool refused 100% under the G (tool allowlist) and LP tiers4 | python -m benchmarks.mcptox replay · re-run 2026-10-17 · results |
| vs. native MCP gateway · 12 malformed payloads | airlock 12/12 blocked · Docker MCP Gateway 0/12 · 0% false-positive on both |
Table notes
Every airlock arm in rows 1 to 6 runs a least-privilege SecurityPolicy(default_deny=True)
with only the tools the task needs on its allowlist (row 5's G0 tier is contract-only), not
the library defaults, under which an empty allowlist allows every tool.
Summary and method: BENCHMARK.md. Every row, with the full results for
each: Benchmarks.
Airlock is in-process. It is not a proxy, a gateway or a sidecar — it runs inside the process that executes the tool, which is the only place the real Python arguments exist.
That makes it complementary to the layers around it, not a replacement for them. A gateway authenticates identity and transport and sandboxes the server; it does not validate the tool call's argument contract. Measured live against a Docker MCP Gateway on 12 malformed payloads, the gateway forwarded 12/12 that airlock blocks.
Both paths use the same @Airlock() decorator. Adapter-shipped means a dedicated module
under src/agent_airlock/integrations/ handles framework-specific glue — signature
preservation, tool-registry rewrites, request-shape adapters. Example-only means the
decorator works out of the box with no adapter.
Adapter-shipped (13): LangChain (integrations/langchain.py),
LangGraph (integrations/langgraph.py at the ToolNode, 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+),
Google ADK (integrations/google_adk.py, v0.9.0+),
FastMCP (agent_airlock/mcp/).
Example-only (2): AutoGen, LlamaIndex — decorator-compatible without an adapter.
| 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_tools(tools, policy=...) for @tools, wrap_agent(agent, policy=...) |
| smolagents | adapter · |
Decorator-ordering rules per framework: docs/COMPATIBILITY.md.
Mapped against the OWASP Agentic Top-10 (2026). Partial means airlock covers the
runtime leg and something upstream is out of scope. Every label is justified per row in
docs/owasp-agentic-2026-coverage.md, which is
generated from a YAML source and byte-diffed in CI.
| Risk | Primary controls | Coverage |
|---|---|---|
| ASI01 Agent Goal Hijack | Sequence guard, action-contradiction gate, tool-output trust guard | Partial |
| ASI02 Tool Misuse and Exploitation | Strict validation, ghost-arg stripping, SafePath / SafeURL, capability gating | Full |
| ASI03 Identity and Privilege Abuse | RBAC, signed agent identity, capability-union boundary, privilege right-sizing | Partial |
| ASI04 Agentic Supply Chain Vulnerabilities | Description and manifest pinning, schema $ref guard, tool-definition pin, attested admission, and 58 CVE/advisory regression tests in tests/cves/ (the 58 distinct CVEs they replay are published in the generated catalog: 50 with a row of their own, 8 inside umbrella modules) | Partial |
| ASI05 Unexpected Code Execution (RCE) | Eval-RCE guards, sandboxed execution, stdio command-injection guards | Full |
| ASI06 Memory & Context Poisoning | Auto-memory provenance, cross-tenant isolation, conversation tracking | Partial |
| ASI07 Insecure Inter-Agent Communication | A2A guard, MCP proxy guard, transport validation | Partial |
| ASI08 Cascading Failures | Circuit breaker, retry policy, amplification budget | Full |
| ASI09 Human-Agent Trust Exploitation | Human-oversight decorator, elicitation guard, honeypot deception | Partial |
| ASI10 Rogue Agents | Audit telemetry, anomaly detector, kill switch — no quarantine primitive | Monitor-only |
Against the MCP Top-10. Evidence is compressed here; each row's full justification is in
docs/owasp-agentic-2026-coverage.md.
| Risk | Primary controls | Coverage |
|---|---|---|
| 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 | require_agent_id preset, capability gating, CredentialScope, runtime capability-union deny at grant time | Full |
| MCP03 Tool Poisoning | Ghost-arg rejection, SafePath / SafeURL, mcp_description_manifest_guard, install_* deny | Full |
| MCP04 Software Supply Chain Attacks & Dependency Tampering | stdio_guard_ox_defaults() (Ox 2026-04-16 advisory), download_plugin_* / fetch_plugin_* deny | Full |
| MCP05 Command Injection & Execution | stdio_guard shell-metachar and deny-pattern rules, exec_* / run_* / system_* deny, CodeSandboxBridgeGuard for code-tool sandbox escapes, sandbox for DANGEROUS | Full |
| MCP06 Intent Flow Subversion | ToolOutputTrustGuard envelopes injected-instruction output; @requires_human_oversight gates high-value actions | Partial |
| MCP07 Insufficient Authentication & Authorization | OAuth 2.1 + PKCE S256 helpers, iss mix-up validation, header/body routing integrity, unsigned-_meta trust boundary, step-up scope guard, Tasks lifecycle and admission guards. Transport-level auth remains server-side | Partial |
| MCP08 Lack of Audit and Telemetry | JSON-Lines audit log, OpenTelemetry export, spans and metrics | Full |
| MCP09 Shadow MCP Servers | Attested tool-server admission, LAN unauthenticated-MCP guard | Partial |
| MCP10 Context Injection & Over-Sharing | PII and secret sanitizer, workspace-scoped config, ToolOutputTrustGuard untrusted-data envelope | Full |
Use it directly via the presets named above, or the per-CVE catalogue at
docs/cves/index.md.
airlock <command> (unified dispatcher, full command set since v0.8.56)airlock scan-tools ./tools.json # static contract check on tool declarations
airlock doctor # environment and config diagnosis
airlock explain --unused-scopes # diff granted privilege against used
airlock attest receipt emit ... # signed evidence that a run was gated
airlock policy compile "..." # English to typed policy (needs an LLM backend)
airlock kill-switch trigger # HMAC-signed fleet freeze
Per-command documentation: CLI reference.
| Metric | Value | Detail | |
|---|---|---|---|
| Validation overhead | ~2µs p50 per decision | deterministic, in-process | |
| Framework integrations | 14 | see above | |
| Core dependencies | 0 | beyond the Pydantic foundation; everything else is an opt-in extra |
This project holds itself to a rule worth stating plainly: a claim in this README is gated by a test, or it is not made.
tests/test_numeric_claim_parity.py and siblings.
A number that drifts fails the build.Known gaps and their status: ROADMAP.md.
| Documentation site | Guides, API reference, CLI, integrations |
BENCHMARK.md | Benchmark summary and method |
docs/cves/index.md | Generated CVE catalogue |
ROADMAP.md | What is not done, and why |
PRIOR_ART.md | External research this rests on |
CHANGELOG.md | Release history |
AGENTS.md | Contributor contract |
Read AGENTS.md first — it is the load-bearing contributor contract — then
CONTRIBUTING.md.
pip install -e ".[dev]"
make test lint
Every feat: needs a regression test. Every CVE fixture needs a primary-source URL in the
commit message. Claims need gates.
Report vulnerabilities via SECURITY.md. Please do not open a public issue
for an unpatched vulnerability.
Apache-2.0. Relicensed from MIT on 2026-09-06 to add an explicit patent grant.
Citation metadata: CITATION.cff.
If this is useful, a ⭐ helps other people find it.
python -m benchmarks.vs_gateway · re-measured live 2026-10-03 · results |
| Prompt injection across agent harnesses | A null result, published as one. claude-code and codex each acted on the planted script 0/36 — and ignored the benign twin just as completely, so this is indifference to the channel, not detection | python -m benchmarks.harness_injection · re-run 2026-10-03 · results |
MCP spec conformance · @modelcontextprotocol/conformance | Wire-path validators, outcome published in full. Not a full MCP server/client conformance pass — airlock is a request validator, not a server | benchmarks/mcp_conformance/RESULTS.md · re-run 2026-10-03 |
| 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 |
| Google ADK | adapter · doc · example | wrap_agent(agent, policy) + tool_context relaxation |
| LlamaIndex | example only | ReActAgent |
| AutoGen | example only | ConversableAgent |