Configuration¶
Agent-Airlock provides flexible configuration through multiple sources.
Configuration Priority¶
Configuration values are loaded in this order (highest priority first):
- Environment variables: only the few listed below.
E2B_API_KEYis the exception: it is used only when no key was given in code or TOML. - Constructor arguments (
AirlockConfig(...)), or the valuesAirlockConfig.from_toml()reads from a configuration file (airlock.toml) - Default values
@Airlock() with no config uses DEFAULT_CONFIG, an AirlockConfig() built when
agent_airlock is imported, so environment variables meant for it must be set before
the import.
AirlockConfig Options¶
The values shown are the defaults.
from agent_airlock import AirlockConfig, UnknownArgsMode
config = AirlockConfig(
# Validation (V0.4.0 - replaces strict_mode)
unknown_args=UnknownArgsMode.STRIP_AND_LOG, # BLOCK / STRIP_AND_LOG / STRIP_SILENT
# Output Sanitization
sanitize_output=True, # Enable output sanitization
mask_pii=True, # Mask PII (email, phone, SSN, credit card, IP address)
pii_locales=[], # ["in"] also masks Aadhaar, PAN, UPI, IFSC, etc.
mask_secrets=True, # Mask secrets (API keys, passwords, etc.)
max_output_chars=20000, # Truncate string results past this (0 = no limit)
max_output_tokens=5000, # Deprecated: never applied, and setting it warns
# E2B Sandbox
e2b_api_key=None, # E2B API key (prefer the E2B_API_KEY env var)
sandbox_timeout=60, # Sandbox execution timeout in seconds
# Filesystem (V0.3.0)
filesystem_policy=None, # FilesystemPolicy for path validation
# Network (V0.3.0)
network_policy=None, # NetworkPolicy for egress control
# Honeypot (V0.3.0)
honeypot_config=None, # HoneypotConfig for deception
# Capability gating (V0.4.0)
capability_policy=None, # CapabilityPolicy that @requires is checked against
# Call-time checks (V0.10.27)
expected_token_audience=None, # this server's resource URI; checks each call's token aud
token_audience_mode="warn", # off / warn / deny, once an audience is set
startup_hook_check="off", # off / warn / deny: .pth import lines, *customize modules
startup_hook_allowlist={}, # {file name: sha256} of the startup hooks you know
# Error Hooks
on_validation_error=None, # Callback for validation errors
on_blocked=None, # Callback for blocked calls
on_rate_limit=None, # Callback for rate limit events
)
Unknown Arguments Mode (V0.4.0)¶
The UnknownArgsMode enum provides explicit control over ghost argument handling:
from agent_airlock import UnknownArgsMode, get_recommended_mode
# Explicit modes
UnknownArgsMode.BLOCK # Reject calls with unknown args (production)
UnknownArgsMode.STRIP_AND_LOG # Strip and log warnings (staging; the default)
UnknownArgsMode.STRIP_SILENT # Silently strip (development)
# Predefined mode constants
from agent_airlock import PRODUCTION_MODE, STAGING_MODE, DEVELOPMENT_MODE
# Recommended mode for an environment name you pass in
mode = get_recommended_mode("production") # BLOCK
# "staging" and "development" give STRIP_AND_LOG; an unrecognised name gives BLOCK
Environment Variables¶
Only these variables are read. Everything else (sanitization, output limits, sandbox
timeout, filesystem and network policy) is set in code or in airlock.toml.
# Unknown Args Mode (V0.4.0); overrides the constructor and the TOML file
export AIRLOCK_UNKNOWN_ARGS=block # or strip_and_log, strip_silent
# Deprecated: sets max_output_tokens, which nothing applies
export AIRLOCK_MAX_OUTPUT_TOKENS=5000
# E2B Sandbox; used when no e2b_api_key is set in code or TOML
export E2B_API_KEY=your-key-here
# Audit log file; used when no audit_log_path is set in code or TOML
export AIRLOCK_AUDIT_LOG_PATH=/var/log/agent-airlock/audit.jsonl
# Deprecated: AIRLOCK_STRICT_MODE=true still maps to block, with a DeprecationWarning
Configuration File¶
Nothing reads airlock.toml on its own. Create one in your project root and load it:
from agent_airlock import Airlock, AirlockConfig
config = AirlockConfig.from_toml("airlock.toml") # from_toml_if_exists() falls back to defaults
@Airlock(config=config)
def my_tool(x: int) -> int:
return x * 2
Keys go under [airlock]; unknown keys are logged and ignored. Rate limits are not a TOML
setting: they belong to SecurityPolicy(rate_limits=...).
[airlock]
unknown_args = "block"
sanitize_output = true
mask_pii = true
mask_secrets = true
max_output_chars = 10000
sandbox_timeout = 30
# V0.10.27 call-time checks; a mode that is not off / warn / deny raises
expected_token_audience = "https://mcp.example.test"
token_audience_mode = "deny"
startup_hook_check = "warn"
# V0.3.0 Filesystem
[airlock.filesystem]
allowed_roots = ["/app/data", "/tmp"]
deny_patterns = ["*.env", "**/.git/**"]
allow_symlinks = false
bind_resolved_paths = false # true: the tool gets the absolute path that was checked
# V0.3.0 Network: only api.company.com, on port 443. allow_egress = false would refuse
# every connection and lookup, and the allowlists would then do nothing
[airlock.network]
allow_egress = true
allowed_hosts = ["api.company.com"]
allowed_ports = [443]
block_private_ips = true # refuse loopback, private, link-local and metadata at connect
# Per-tool URL rules (step 5), keyed by tool name
[airlock.endpoints.fetch]
allowed_endpoints = ["api.company.com"]
resolve_hostnames = true # default: resolve an admitted name, refuse internal answers
deny_on_resolution_failure = true # default: refuse a name that does not resolve
# Optional path scope: only these scheme://host[:port]/path/ prefixes (default: no scope)
allowed_url_prefixes = ["https://api.company.com/v1/tools/"]
# V0.3.0 Honeypot
[airlock.honeypot]
strategy = "honeypot"
fake_delay_ms = 100
# V0.10.27 startup hooks you know, by file name
[airlock.startup_hook_allowlist]
"distutils-precedence.pth" = "<the file's sha256, 64 hex characters>"
The token-audience and startup-hook keys are described in Token audience and startup hooks.
Filesystem Paths¶
FilesystemPolicy decides which paths a call may name; it does not open them. Its check
runs before the tool, against the path as it resolves then, so it is not atomic: a
directory swapped for a symlink after the check is followed by the tool's own open().
Open through open_contained_file (or open_contained, for a raw descriptor) to have the
open walk the checked path from the allowed root without following a symlink, and set
bind_resolved_paths=True to hand the tool the absolute path that was checked. What each
covers, and the platforms the atomic open needs, are in
Core Concepts.
from pathlib import Path
from agent_airlock import Airlock, AirlockConfig, FilesystemPolicy, open_contained_file
policy = FilesystemPolicy(allowed_roots=[Path("/srv/app/data")], bind_resolved_paths=True)
@Airlock(config=AirlockConfig(filesystem_policy=policy))
def write_report(path: str, text: str) -> str:
with open_contained_file(path, policy, "w") as handle:
handle.write(text)
return path
Per-Tool Configuration¶
Override configuration for specific tools:
from agent_airlock import Airlock, AirlockConfig, UnknownArgsMode
# Global config (permissive for development)
default_config = AirlockConfig(unknown_args=UnknownArgsMode.STRIP_SILENT)
# Strict config for sensitive operations
strict_config = AirlockConfig(
unknown_args=UnknownArgsMode.BLOCK,
mask_pii=True,
)
@Airlock(config=default_config)
def search_users(query: str) -> list:
return [...]
@Airlock(config=strict_config)
def delete_user(user_id: int) -> dict:
return [...]
Endpoint Policies and Private Addresses¶
AirlockConfig(endpoint_policies={tool_name: EndpointPolicy(...)}) checks every URL a tool's
arguments carry before the tool runs (step 5). Since 0.10.29 it reads the address a host
stands for, not the literal string: decimal (http://2130706433/), hex
(http://0x7f000001/), octal (http://0251.0376.0251.0376/), short (http://127.1/) and
IPv4-mapped IPv6 forms are refused like the addresses they decode to. A hostname that passes
the blocked patterns and allowed_endpoints is then resolved, and refused when any address it
resolves to is private, loopback, link-local or a cloud metadata address
(http://169.254.169.254.nip.io/ included), or when it does not resolve. Only names the
policy would admit are looked up, so a name it refuses is never sent to DNS.
That lookup adds the resolver's latency to each call with a hostname URL. An allowlisted
name that resolves to an internal service needs allow_private_ips=True, and a test that
allowlists a made-up name needs resolver= (a function from a hostname to its addresses) or
resolve_hostnames=False. The escape hatch is for private ranges, not a rebind to metadata:
an allowed_endpoints host (and, for SSRFEgressGuard, an allow_internal_hosts host) that
resolves to a cloud metadata address is refused unless allow_metadata_urls=True.
allowed_url_prefixes scopes the allowlist to a path, not just a host (CVE-2026-120492): an
operator who wants only https://api.example.com/v1/tools/ lists that, and a URL is refused
unless one entry matches on scheme, host and port and the URL's path, with its dot segments
and servlet path parameters resolved, stays under the entry's path at a segment boundary. So
/v1/tools/..;/admin and /v1/tools/%2e%2e;x/admin, which a servlet container collapses to
/v1/admin, are refused, and a path carrying %2f, %5c, a backslash or %00 is refused
outright. An empty list (the default) keeps host-only behaviour. The host, blocked-pattern,
private and metadata checks all run before the path check.
NetworkPolicy(block_private_ips=True) refuses the same internal addresses inside the network
airgap, at every connect and datagram the tool makes during the call. allowed_hosts does not
exempt an address from it; allowed_private_ips does (a local egress proxy, a sidecar).
from agent_airlock import Airlock, AirlockConfig, EndpointPolicy, NetworkPolicy
config = AirlockConfig(
endpoint_policies={
"fetch": EndpointPolicy(
allowed_endpoints=["api.example.com"],
allowed_url_prefixes=["https://api.example.com/v1/tools/"],
)
},
network_policy=NetworkPolicy(block_private_ips=True, allowed_private_ips=["127.0.0.1"]),
)
@Airlock(config=config)
def fetch(url: str) -> str:
return url
What the call-path checks cannot see¶
- Step 5 reads the URLs in the tool's arguments, once, before the tool runs. A redirect the tool follows, a page that navigates or loads another URL, and a name the tool resolves again later (DNS rebinding) are not arguments, so step 5 never sees them.
- The airgap sees them only with
block_private_ipson, and only for the connects and datagrams the call's own context makes: the tool's code, the asyncio tasks it creates andasyncio.to_thread. A thread the tool starts itself, a subprocess (a headless browser, acurl), a socket made from_socketdirectly, an event loop that does its own socket I/O (uvloop) and other processes are outside it (see The Network Airgap). - A name passed straight to
socket.connectis resolved by the airgap and then again by the connect itself, so a record that changes between the two lookups is not seen. Clients that look up first and connect to the address (socket.create_connection,urllib,requests,httpx) are not exposed to that.
Capability Gating (V0.4.0)¶
Configure fine-grained permissions per tool. @requires declares what a tool needs;
@Airlock checks that against a capability policy, taken from the SecurityPolicy
passed as policy= or, when that has none, from AirlockConfig(capability_policy=...).
With no capability policy set, @requires is not enforced.
from agent_airlock import (
Airlock, AirlockConfig, Capability, requires,
STRICT_CAPABILITY_POLICY, READ_ONLY_CAPABILITY_POLICY,
)
# Using predefined policies
config = AirlockConfig(capability_policy=READ_ONLY_CAPABILITY_POLICY)
# Using decorator
@Airlock(config=config)
@requires(Capability.FILESYSTEM_READ)
def read_file(path: str) -> str: ... # allowed: FILESYSTEM_READ is granted
@Airlock(config=config)
@requires(Capability.FILESYSTEM_READ | Capability.NETWORK_HTTP)
def fetch_and_save(url: str, path: str) -> bool: ... # blocked: NETWORK_HTTP is not granted
Safe Types (V0.4.0)¶
Use built-in safe types for automatic validation:
from agent_airlock import SafePath, SafePathStrict, SafeURL, SafeURLAllowHttp
# Path types
def read_file(path: SafePath) -> str:
"""Validates against traversal attacks."""
...
def write_config(path: SafePathStrict) -> bool:
"""Same checks, and also rejects absolute paths."""
...
# URL types
def fetch_api(url: SafeURL) -> dict:
"""Validates HTTPS only."""
...
def fetch_legacy(url: SafeURLAllowHttp) -> dict:
"""Allows both HTTP and HTTPS."""
...
Circuit Breaker (V0.4.0)¶
Configure fault tolerance for external dependencies. Airlock takes no circuit-breaker
argument: CircuitBreaker(name, config) is its own decorator. Stack it under @Airlock
so it sees the tool's exceptions; Airlock turns an exception into a blocked response, so a
breaker stacked above it never counts a failure.
from agent_airlock import (
Airlock, CircuitBreaker, CircuitBreakerConfig,
AGGRESSIVE_BREAKER, CONSERVATIVE_BREAKER,
)
# Predefined configs
breaker = CircuitBreaker("external-api", AGGRESSIVE_BREAKER) # Opens after 3 failures
@Airlock()
@breaker
def risky_external_call(query: str) -> dict: ...
# Custom config
breaker = CircuitBreaker("payments-api", CircuitBreakerConfig(
failure_threshold=5, # Open after 5 consecutive failures
timeout=30.0, # Allow a trial call after 30s
success_threshold=2, # Close again after 2 successes
))
Cost Tracking (V0.4.0)¶
Monitor and limit API spending. Airlock takes no cost-tracker argument: a CostTracker
records the token usage you report to it. Amounts are Decimal, and a limit of 0 is a
limit (nothing may be spent). A limit blocks a call before it runs when the limit is
already reached, or when you pass a cost estimate that would cross it
(track("tool", estimate=TokenUsage(...)), or check_admission(estimate)). Otherwise the
call that crosses runs and is recorded, BudgetExceededError is raised, and every later
call is refused until reset(): the most a session overshoots is one call.
from decimal import Decimal
from agent_airlock import CostTracker, BudgetConfig, BudgetExceededError
tracker = CostTracker(budget=BudgetConfig(
max_cost_per_session=Decimal("100"), # Fail if exceeded
warn_at_percentage=80.0, # Log a budget_warning from 80%
))
tracker.add_callback(my_alert) # Called with every CostRecord
with tracker.track("expensive_tool") as call:
result = call_expensive_api(query)
call.set_tokens(input_tokens=1200, output_tokens=300)
The budget @Airlock itself checks before a call runs is the per-model-tier one,
SecurityPolicy(model_tier_budget=...), with each call tagged through AirlockContext
metadata rather than its arguments. See the Policy API.
Retry Policies (V0.4.0)¶
Configure automatic retry with backoff. Airlock takes no retry argument: RetryPolicy
is its own decorator. Stack it under @Airlock, so it retries the tool body and never a
call Airlock has already rejected.
from agent_airlock import (
Airlock, RetryPolicy, RetryConfig,
FAST_RETRY, STANDARD_RETRY, PATIENT_RETRY,
)
# Predefined configs
@Airlock()
@RetryPolicy(STANDARD_RETRY) # 3 retries, 1s base delay
def flaky_api_call(query: str) -> dict: ...
# Custom policy
policy = RetryPolicy(RetryConfig(
max_retries=3, # Retries after the first attempt
base_delay=0.1,
max_delay=5.0,
exponential_base=2.0,
jitter=True,
retryable_exceptions=(ConnectionError, TimeoutError),
))
When the retries run out, RetryExhaustedError is raised.
Masking Strategies¶
@Airlock masks each detected value with that type's default strategy: FULL for SSN,
password, private key and connection string, 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(
"john@example.com",
mask_config={SensitiveDataType.EMAIL: MaskingStrategy.PARTIAL}, # Show partial data
)
Available strategies:
| Strategy | Example | Result |
|---|---|---|
FULL |
john@example.com |
[REDACTED] |
PARTIAL |
john@example.com |
j***@example.com |
TYPE_ONLY |
john@example.com |
[EMAIL] |
HASH |
john@example.com |
[HMAC:3f9a8c2e1b7d4065] (keyed per request) |
Sensitive Data Types¶
AirlockConfig selects types by group only: mask_pii, mask_secrets and pii_locales.
To mask specific types, call the standalone mask_sensitive_data, or use
WorkspacePIIConfig(enabled_types=...) (see Workspace Configuration):
from agent_airlock import SensitiveDataType, mask_sensitive_data
masked, detections = mask_sensitive_data(
"john@example.com, SSN 123-45-6789",
types=[SensitiveDataType.SSN, SensitiveDataType.CREDIT_CARD],
)
# masked == "john@example.com, SSN [REDACTED]"
Available types:
Standard (mask_pii=True):
- EMAIL - Email addresses
- PHONE - Phone numbers
- SSN - Social Security Numbers
- CREDIT_CARD - Credit card numbers
- IP_ADDRESS - IP addresses
Secrets (mask_secrets=True):
- API_KEY - API keys (OpenAI, Anthropic, Google, GitHub, Slack formats)
- AWS_KEY - AWS access keys
- PASSWORD - Password patterns
- JWT - JSON Web Tokens
- CONNECTION_STRING - Database connection strings
- PRIVATE_KEY - Private key markers
India-Specific (mask_pii=True with pii_locales=["in"]):
- AADHAAR - 12-digit Aadhaar numbers (with Verhoeff validation)
- PAN - Permanent Account Number
- UPI_ID - UPI identifiers
- IFSC - Bank IFSC codes
- PERSONAL_NAME_DEVANAGARI - Names in Devanagari script
- INDIA_MOBILE - Mobile numbers with a +91, 91 or 0 prefix
OpenTelemetry Observability (V0.4.0)¶
Configure enterprise-grade monitoring:
from agent_airlock import configure_observability, OpenTelemetryProvider
configure_observability(OpenTelemetryProvider(
service_name="my-agent-service",
endpoint="http://otel-collector:4317", # Optional
))
# Use observe() context manager
from agent_airlock import observe
with observe("my_operation", tool_name="my_tool") as span:
span.set_attribute("key", "value")
result = do_work()
Workspace Configuration¶
For multi-tenant applications:
from agent_airlock import WorkspacePIIConfig, sanitize_with_workspace_config
config = WorkspacePIIConfig(
workspace_id="enterprise-acme",
allow_email_domains=["acme.com"], # Don't mask internal emails
mask_email_domains=["competitor.com"], # Mask only these; other emails stay
custom_patterns={
"employee_id": r"EMP-\d{6}",
},
)
result = sanitize_with_workspace_config(content, config) # masked text: result.content
Error Hooks¶
Register callbacks for monitoring:
from agent_airlock import AirlockConfig
from pydantic import ValidationError
def log_validation_error(tool_name: str, error: ValidationError):
print(f"Validation failed for {tool_name}: {error}")
def log_blocked(tool_name: str, reason: str, context: dict):
print(f"Blocked {tool_name}: {reason}")
def log_rate_limit(tool_name: str, retry_after: int):
print(f"Rate limited {tool_name}, retry in {retry_after}s")
config = AirlockConfig(
on_validation_error=log_validation_error,
on_blocked=log_blocked,
on_rate_limit=log_rate_limit,
)