Security Best Practices for Agent-Airlock¶
This document provides security guidelines for using Agent-Airlock in production MCP servers.
Table of Contents¶
- Threat Model
- Defense-in-Depth
- Configuration Guidelines
- Policy Engine
- Sandbox Execution
- Output Sanitization
- Audit Logging
- Reporting Vulnerabilities
Threat Model¶
Agent-Airlock protects against these AI agent attack vectors:
1. Hallucinated Arguments¶
Threat: LLMs invent parameters that don't exist in tool signatures.
Example:
# Tool expects: read_file(path: str)
# LLM sends: read_file(path="data.txt", force=True, admin=True)
Mitigation: Ghost argument stripping (the default) or rejection (UnknownArgsMode.BLOCK).
2. Type Coercion Attacks¶
Threat: LLMs send wrong types expecting implicit conversion.
Example:
Mitigation: Pydantic V2 strict mode - no type coercion allowed.
3. Prompt Injection via Tool Arguments¶
Threat: Malicious content in tool arguments designed to manipulate subsequent LLM behavior.
Example:
Mitigation: Output sanitization + policy-based content filtering.
4. Resource Exhaustion¶
Threat: Tools that consume excessive compute, memory, or API calls.
Example:
Mitigation: Rate limiting, output truncation, and sandbox resource limits.
5. Privilege Escalation¶
Threat: Agents attempting to access tools beyond their authorization level.
Example:
Mitigation: RBAC policy engine with role-based tool access.
6. Data Exfiltration¶
Threat: Sensitive data leaking through tool outputs back to the LLM.
Example:
Mitigation: PII/secret detection and masking in output sanitization.
Defense-in-Depth¶
Agent-Airlock implements multiple security layers:
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Input Validation │
│ • Ghost argument detection │
│ • Pydantic strict schema validation │
│ • Type checking with no coercion │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Policy Enforcement │
│ • Tool allow/deny lists │
│ • Rate limiting (token bucket) │
│ • Time-based restrictions │
│ • Agent role verification │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: Execution Isolation │
│ • Local execution (trusted tools) │
│ • E2B Firecracker MicroVM (untrusted code) │
│ • Resource limits (CPU, memory, network) │
├─────────────────────────────────────────────────────────────┤
│ Layer 4: Output Protection │
│ • PII detection and masking │
│ • Secret/API key removal │
│ • Output size truncation │
│ • Audit logging │
└─────────────────────────────────────────────────────────────┘
Configuration Guidelines¶
Recommended Production Configuration¶
from agent_airlock import Airlock, AirlockConfig, STRICT_POLICY, UnknownArgsMode
config = AirlockConfig(
unknown_args=UnknownArgsMode.BLOCK, # Reject unknown arguments
mask_pii=True, # Mask SSN, credit cards, etc.
mask_secrets=True, # Mask API keys, passwords
max_output_chars=10000, # Prevent token explosion
sanitize_output=True, # Enable all output protection
)
# STRICT_POLICY refuses any call that carries no agent identity
@Airlock(config=config, policy=STRICT_POLICY)
def my_secure_tool(args: MyArgs) -> dict:
...
Environment Variables¶
| Variable | Description | Default |
|---|---|---|
AIRLOCK_UNKNOWN_ARGS |
block rejects unknown arguments; also strip_and_log, strip_silent |
strip_and_log |
AIRLOCK_STRICT_MODE |
Deprecated; true maps to block, any other value to strip_and_log (and wins over AIRLOCK_UNKNOWN_ARGS) |
unset |
E2B_API_KEY |
E2B sandbox API key, used when none is set in code | None |
PII and secret masking have no environment variable. Both are on by default
(mask_pii=True, mask_secrets=True); turn them off in code.
Unknown-Argument Modes¶
Set with AirlockConfig(unknown_args=...). strict_mode=True is the deprecated spelling
of BLOCK.
| Mode | Behavior | Use Case |
|---|---|---|
STRIP_AND_LOG (default) |
Strip unknown args, log warning | Development, backward compatibility |
BLOCK |
Reject call, return error | Production, high-security environments |
STRIP_SILENT |
Strip unknown args, no warning | Isolated development only |
Policy Engine¶
Predefined Policies¶
from agent_airlock import (
PERMISSIVE_POLICY, # No restrictions
STRICT_POLICY, # Requires agent ID
READ_ONLY_POLICY, # Blocks write/delete/modify tools
BUSINESS_HOURS_POLICY # delete_*, drop_*, *_production only 9 AM - 5 PM
)
Custom Policies¶
from agent_airlock import SecurityPolicy
PRODUCTION_POLICY = SecurityPolicy(
# Tool access control
allowed_tools=["read_*", "query_*", "get_*"],
denied_tools=["delete_*", "drop_*", "truncate_*"],
# Agent identity requirements; how a call carries one: guide/policy.md#agent-identity
require_agent_id=True,
allowed_roles=["analyst", "developer"],
# Rate limiting
rate_limits={
"query_*": "100/minute",
"*": "1000/hour",
},
# Time restrictions
time_restrictions={
"write_*": "09:00-17:00", # Business hours only
},
)
Rate Limit Patterns¶
rate_limits={
"expensive_api": "10/minute", # Specific tool
"query_*": "100/minute", # Wildcard pattern
"*": "1000/hour", # Global fallback
}
Sandbox Execution¶
When to Use Sandbox¶
Use sandbox=True for tools that:
- Execute user-provided code
- Process untrusted file content
- Make network requests to arbitrary URLs
- Perform filesystem operations
CRITICAL: Use sandbox_required=True for Dangerous Operations¶
When E2B is not installed or configured, a sandbox=True call is refused with a blocked
response. sandbox=True alone still leaves one local fallback: if agent_airlock.sandbox
itself fails to import, the function runs in your process. sandbox_required=True refuses
that case too, so it is the setting for tools that execute arbitrary code.
# Refused when E2B is unavailable, but runs locally if agent_airlock.sandbox
# cannot be imported
@Airlock(sandbox=True)
def execute_code(code: str) -> str:
exec(code)
return "executed"
# SECURE: never runs in your process
@Airlock(sandbox=True, sandbox_required=True)
def execute_code(code: str) -> str:
"""Runs in an isolated E2B micro-VM, or not at all."""
exec(code)
return "executed"
Always use sandbox_required=True for:
- Code execution (exec(), eval())
- Shell command execution
- Any operation that could compromise the host system
Sandbox Limitations¶
- Cold start: E2B reports about 125ms for its Firecracker micro-VMs; not measured by this
project. A warmed pool (
get_sandbox_pool(config).warm_up()) creates sandboxes before the first call. - Sandbox lifetime:
sandbox_timeoutseconds, 60 by default, from when a sandbox is created or handed out of the pool. A call still running then fails; there is no other per-call limit. - One call per sandbox. The pool kills a sandbox after its call, failed or not, instead of handing it to the next call, so files or state one call leaves behind never reach another. Until 0.10.17 sandboxes were reused within a process.
- Outbound network is E2B's default, which allows internet access: Airlock sets no network
restriction on E2B sandboxes.
DockerBackendruns withnetwork_mode="none"by default. - 24-hour session cap (E2B limitation)
E2B API Key Security¶
# Store in environment (recommended)
export E2B_API_KEY="your-key-here"
# Or in config file (less secure), loaded with AirlockConfig.from_toml("airlock.toml")
# airlock.toml
[airlock]
e2b_api_key = "your-key-here" # Ensure file permissions are restricted
A key set in code or in the file takes precedence; E2B_API_KEY is used only when neither
sets one.
Pickle Serialization Security¶
Agent-Airlock uses cloudpickle to serialize functions and arguments for
sandbox execution. This is inherently risky because pickle can execute
arbitrary code during deserialization.
Why this is acceptable:
1. Deserialization occurs INSIDE the E2B sandbox (isolated MicroVM)
2. Even if malicious code executes, it's contained in the sandbox
3. The sandbox has no access to your host filesystem or memory
4. Nothing comes back as a pickle. The outcome is printed as JSON, and the host parses it as
JSON: code running in the sandbox controls its own output, so unpickling that output
would let it run code on the host. ModalBackend did unpickle it until 0.10.17.
For high-security environments, consider: - Adding HMAC signing to verify payload integrity before sending to sandbox - Implementing a restricted unpickler that validates types - Using JSON serialization for simple argument types
Risk assessment: - If an attacker can modify the pickle payload in transit → RCE in sandbox only - Sandbox isolation prevents host compromise - This is defense-in-depth: validation + isolation + sanitization
Rate Limit State Persistence¶
Note: Rate limit state is stored in memory and resets on process restart. In distributed deployments, consider: - Using external storage (Redis) for rate limit state - Accepting per-instance rate limiting as a temporary measure - Documenting this limitation in your deployment guide
Output Sanitization¶
PII Detection¶
Automatically detects and masks:
- Social Security Numbers (XXX-XX-XXXX)
- Credit Card Numbers (Visa, Mastercard, Amex, Discover; the digits may be split by one
consistent - or space)
- Email Addresses
- Phone Numbers
- IP Addresses
With pii_locales=["in"], also Aadhaar, PAN, UPI IDs, IFSC codes, Devanagari names and
Indian mobile numbers.
Secret Detection¶
Automatically detects and masks:
- API Keys of known shapes: OpenAI keys (legacy sk-... and sk-proj-/sk-svcacct-/
sk-admin-), Anthropic sk-ant-..., Google AIza..., GitHub ghp_/gho_/github_pat_,
Slack xox*. Others, such as sk-live-... or api_key=..., are not detected.
- Private keys: the whole -----BEGIN ... PRIVATE KEY----- block
- AWS Access Keys (AKIA...)
- JWT Tokens (eyJ...)
- Connection Strings (postgres://, mongodb://)
- Generic Passwords (8+ characters after password=, pwd=, secret=, token=, etc.)
A string, dict, list, tuple or set result is masked, dictionary keys included. What a
generator tool yields is masked item by item the same way, and each step of it runs under
the call's network airgap (until 0.10.20 it did neither). Since 0.10.28 a coroutine or a
lazy iterator a tool returns (map, filter, an iterator class that is not a context
manager) is guarded the same way, and an Airlock refusal raised while one of them runs is
audited as a block before the consumer gets the exception. Any other object (a Pydantic
model, a dataclass), returned or inside a container, is returned as it is, and what was
found in it is logged as not masked. See PII & Secret Masking.
Masking Strategies¶
@Airlock masks each type with its default strategy: FULL for SSN, passwords, private
keys and connection strings, TYPE_ONLY for IFSC, PARTIAL for the rest. AirlockConfig
has no strategy setting. To choose one, run the standalone sanitizer with a mask_config
mapping; a type left out of the mapping is masked FULL:
from agent_airlock import MaskingStrategy, SensitiveDataType, sanitize_output
result = sanitize_output(
text,
mask_config={
SensitiveDataType.SSN: MaskingStrategy.PARTIAL, # First and last 3 chars
SensitiveDataType.API_KEY: MaskingStrategy.FULL, # Complete redaction
},
)
| Strategy | Example (SSN 123-45-6789) |
|---|---|
FULL |
[REDACTED] |
PARTIAL |
123***789 |
TYPE_ONLY |
[SSN] |
HASH |
[HMAC: and 16 hex characters ], keyed per request (0.10.29) |
Audit Logging¶
Log Format¶
Calls through @Airlock are appended to a JSON Lines file, one record per line:
airlock_audit.json in the working directory by default. Set audit_log_path, or the
AIRLOCK_AUDIT_LOG_PATH environment variable when no path is given in code or TOML, to put
it elsewhere; enable_audit_log=False turns it off. A log that cannot be created is
reported once (audit_log_unavailable) and nothing is recorded, rather than failing the
import.
A call to delete_records refused by a denied_tools=["delete_*"] policy:
{
"timestamp": "2026-01-31T10:30:00.123456+00:00",
"tool_name": "delete_records",
"blocked": true,
"block_reason": "policy_violation",
"duration_ms": 0.11,
"sanitized_count": 0,
"truncated": false,
"args_preview": {"table": "'users'", "where": "'id=1'"},
"result_type": "None",
"result_preview": "BLOCKED",
"error": "AIRLOCK_BLOCK: Policy violation for 'delete_records'. Tool 'delete_records' is denied by policy",
"rule_id": "delete_*",
"policy_layer": "policy"
}
Fields with no value are left out. A refusal's record names the rule that refused it
(rule_id: a deny or escalation pattern, a violation type, else the block reason) and the
call-path layer it sits in (policy_layer), since 0.10.29. The refusal the model reads
carries neither: a deny or escalation pattern is the operator's, and quoting it helps a
search for a name the pattern misses. agent_id and session_id come from the context a run
wrapper carries as the tool's first argument (an object model JSON cannot have built, such
as a framework's run context; see Agent Identity), or,
when the call has none, from one set around it with with AirlockContext(...). Argument
values whose names look sensitive (password, token, api_key, ...) are written as
[REDACTED], and every other argument preview is passed through the PII and secret masker
before it is cut to length, so an email address or key given in any argument is not
written as-is. Until 0.10.20 only the names were checked. result_preview is masked the
same way, whatever sanitize_output is set to; until 0.10.28 it held the raw text with
sanitize_output=False, or for a result returned unmasked (a model, a dataclass).
The refusal the model reads holds no argument value either: a validation error names the
field and the type it got (input_type), not the value, and a policy resolver or approver
that raised is named by its exception type, its message going only to the log. Log lines
that carry an argument value (a refused path or URL) write it masked the same way and cut
to 200 characters. Until 0.10.29 a validation refusal returned repr(value), so a key sent
to the wrong argument came back to the model unmasked.
Log Destinations¶
The library's own log lines (not the audit file) go through structlog when the
[logging] extra is installed, and through the stdlib logging module otherwise:
import structlog
# Configure logging backend
structlog.configure(
processors=[
structlog.processors.JSONRenderer()
],
logger_factory=structlog.PrintLoggerFactory(),
)
Reporting Vulnerabilities¶
If you discover a security vulnerability in Agent-Airlock:
- Do NOT open a public GitHub issue
- Report it privately via GitHub Security Advisories, or email sattyamjain96@gmail.com
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We aim to respond within 48 hours and provide a fix within 7 days for critical issues.
Security Checklist¶
Before deploying to production:
- [ ] Set
unknown_args=UnknownArgsMode.BLOCK - [ ] Configure appropriate
SecurityPolicy - [ ] Enable PII and secret masking
- [ ] Set reasonable
max_output_charslimit - [ ] Use
sandbox=Truefor code execution tools - [ ] Use
sandbox_required=Truefor exec()/eval() tools - [ ] Store E2B API key in environment variable
- [ ] Configure audit logging
- [ ] Review and test rate limits
- [ ] Validate file paths to prevent directory traversal
- [ ] Test with adversarial inputs
OWASP LLM Top 10 Mapping (2025)¶
Agent-Airlock provides mitigations for these OWASP LLM Application Security risks:
| OWASP Risk | Agent-Airlock Mitigation |
|---|---|
| LLM01: Prompt Injection | Strict type validation rejects malformed inputs; no implicit type coercion |
| LLM05: Improper Output Handling | PII/secret detection + masking sanitizes string and container (dict, list, tuple, set) tool outputs |
| LLM06: Excessive Agency | Rate limiting, time restrictions, and RBAC policies constrain agent actions |
| LLM09: Misinformation | Ghost argument rejection prevents hallucinated parameters from executing |
| LLM10: Unbounded Consumption | Output truncation limits token usage; rate limiting prevents API abuse |