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.

PyPI version Downloads CI codecov

Python 3.10+ License: MIT GitHub stars PRs Welcome

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% of tool_knowledge injection→task target tool-calls on the pinned workspace+banking subset (deterministic upper bound on ASR reduction, not the model-in-the-loop ASR); see benchmarks/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:

  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 ($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


🔥 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.


🛑 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.

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 @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 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 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. The new console-script entry airlock-explain is the project's first installable CLI; existing python -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 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. The shipped surface is the airlock-scan-tools console script (and python -m agent_airlock.cli.scan_tools); the unified airlock scan-tools space-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