A deny-by-default type-checker and contract layer for AI agent tool calls — the in-process check that runs beneath your MCP gateway
Strict validation, ghost-argument stripping, and self-healing retries — one decorator, any agent or MCP server. Zero core dependencies.[^deps]
Test suite: 3,861 tests · Coverage: 86.84% · v0.8.73 Coverage runs 3,849 of 3,861; 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 · 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 · 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) · 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 · 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) · benchmarks/mcp_conformance/RESULTS.md (MCP spec 2026-07-28)
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.
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 (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.
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.
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 has an approval primitive (requires_human_oversight, v0.8.4) but it is a separate decorator with an operator-supplied approver and no UI; @Airlock's own verdict stays binary, so a SecurityPolicy cannot escalate a call to a human (#143).
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:
- 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.
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 aga
No comments yet
Be the first to share your take.