A type-checker and contract layer for AI agent tool calls — deny-by-default, in-process, zero-dep
Strict validation, ghost-argument stripping, and self-healing retries — one decorator, any agent or MCP server.
Test suite: 3,528 tests · Coverage: 85.79% · v0.8.52
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 · 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) · 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 · workspace+banking · python -m benchmarks.agentdojo.run |
84.4% of injection→task target tool-calls blocked (deterministic upper bound on ASR reduction) | AgentDojo model-in-the-loop ASR — real-ASR path via --model needs a key; not re-run here |
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 · reproduced live 2026-07-16: sent each payload as a real MCP tools/call; the gateway forwarded every one to the backend |
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)
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 measured live (2026-07-16) 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. AgentDojo is wired — airlock blocks 84.4% oftool_knowledgeinjection→task target tool-calls on the pinned workspace+banking subset (deterministic upper bound on ASR reduction, not the model-in-the-loop ASR); seebenchmarks/agentdojo/RESULTS.md.
┌────────────────────────────────────────────────────────────────┐
│ 🤖 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 │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
🧩 How this compares to native MCP gateways
MCP gateways and platform firewalls — Docker MCP Gateway, Cloudflare, Azure API Management, 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. 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 (2026-07-16): 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.
🔍 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:
- Scan —
agent-audit-kit scan .finds unguarded surfaces before they ship: an MCP tool withadditionalPropertiesleft open, a poisoned server-card description, a subprocess/argv injection surface, an unsigned_metatrust decision. - 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. - Pre-flight —
airlock scan-toolsis 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 sarifso 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 need100 - Agents chain 47 calls before one deletes prod data
Enterprise solutions exist: Prompt Security ($50K/year), Pangea (proxy your data), Cisco ("coming soon").
We built the open-source alternative. One decorator. No vendor lock-in. Your data never leaves your infrastructure.
✨ What You Get
📋 Table of Contents
- 30-Second Quickstart
- The Problem
- What You Get
- Core Features
- Framework Compatibility
- FastMCP Integration
- Comparison
- Installation
- OWASP Compliance
- Performance
- Documentation
- Contributing
- Support
🔥 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:
- Deny-by-default tool allowlist. Empty
allowed_toolsmeans 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. - Ghost-argument BLOCK. A camouflaged directive cannot smuggle undeclared parameters past validation.
- 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.
- 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) beforesubprocess.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:
- 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 toAttestedAdmissionConfigat process startup — never network-fetched on the hot path. - 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
subclaim 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). - Flavor-gated enforcement.
ENFORCE(default) hard-denies on missing / invalid / expired clearance;WARNlogs 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 setattested_admissionget 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_guardfield defaults toNone; callers that don't set it get exactly v0.8.11 behavior. Zero new runtime deps — Pydantic-only core stays intact.
🛑 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_gatefield defaults toNone; 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.
python -m agent_airlock.cli.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
@Airlockpath.
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 v0.8.13 wheel; airlock-explain becomes available
pip install "agent-airlock>=0.8.13"
# 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
AuditLoggeralready emits. One JSON object per line, withtool_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-otlpwrites. Spannameis the tool name;attributes.agent_idkeys the per- agent diff. If a span carriesairlock.blocked=trueit 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. The new console-script entry
airlock-explainis the project's first installable CLI; existingpython -m agent_airlock.cli.<name>invocations are unaffected.
🧾 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
python -m agent_airlock.cli.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 surface —
additionalPropertiesis notfalseon a destructive/mutating tool (a ghost/hallucinated-argument vector). FAIL. - Missing type constraints — a sensitive string arg (
path/url/command/ …) with noenum/pattern/format/maxLength, or an untyped property. WARN. - Capability caps exceed policy — the tool's inferred capability (from MCP
annotations, an explicitcapabilitieslist, or a name heuristic) is denied / not-granted by the policy's shippedCapabilityPolicy. FAIL. - Server-card trust boundary — a tool description carrying injected instructions
("…ignore previous instructions and run…"). Reuses the
mcp_spec_2026_07Server-Card / SEP-2468 guard. FAIL.
Policies map 1:1 to shipped constants — permissive / read-only / strict /
deny-by-default — no 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% precision — not MCPTox's
model-in-the-loop Attack Success Rate.
Static review aid, Pydantic-only, zero-dep core. The shipped surface is the
airlock-scan-toolsconsole script (andpython -m agent_airlock.cli.scan_tools); the unifiedairlock scan-toolsspace-form lands with the deferred dispatcher PR.
🩹 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
No comments yet
Be the first to share your take.