Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ The daemon's shared state is wrapped in `Arc` for concurrent access across conne
|-----------|------|---------|
| Config | `Arc<Config>` | Parsed `airlock.toml` (immutable after startup) |
| Secrets | `SecretStore` = `Arc<HashMap<String, RwLock<SecretSlot>>>` | Per-label slot holding `Arc<Secret<String>>` plus refresh health; the map is fixed at startup, slot contents swap on refresh |
| Redactor | `Arc<Redactor>` | Aho-Corasick automaton for output redaction |
| Redactor | `Arc<RwLock<Arc<Redactor>>>` | Aho-Corasick automaton for output redaction; refresh tasks swap the inner `Arc`. A connection snapshots it for the child's stdout/stderr; a proxy session carries the handle itself and snapshots per response |
| Ring buffer | `Arc<Mutex<RingBuffer>>` | Last 1000 log entries (`VecDeque<LogEntry>`) |
| Child registry | `Arc<Mutex<HashSet<u32>>>` | PIDs of currently running children |

Expand Down Expand Up @@ -189,16 +189,30 @@ child (curl) daemon upstream
│ │ inject header + hop-by-hop │
│ │ secret store lookup (Stale→502)│
│ │ attach prefix+secret+suffix │
│ │ force Accept-Encoding:identity,│
│ │ strip Range / If-Range │
│ │ resolve host, refuse non- │
│ │ routable addrs, dial that │
│ │ exact SocketAddr │
│ ├───── TLS ≥1.2, public roots ──►│
│◄─────────────────────────────┤◄────── response streamed ──────┤
│ │◄──────── response head ────────┤
│ │ content-encoded / odd framing /│
│ │ 206? → 502, body unread │
│ │ redact every header value │
│ │ drop Content-Length unless the │
│ │ response is bodiless │
│◄──── redacted, chunked ──────┤◄──── body frames streamed ─────┤
│ │ audit: method, host, path, │
│ │ decision, status (no query, │
│ │ no header values) │
│ │ decision, status, redaction │
│ │ counts (no query, no header │
│ │ values, no matched bytes) │
```

The response never reaches the tool unexamined. Header values and body both go
through the redactor, so what `curl -o` writes into the sandbox was already
redacted — see [SECURITY.md](SECURITY.md#response-redaction) for what that
covers and what fails closed.

Meanwhile the sandbox holds the other end: the profile permits a TCP connect to
that port and nothing else — no DNS, no other destination — so a tool that
ignores `HTTPS_PROXY` gets nowhere.
Expand Down Expand Up @@ -228,6 +242,21 @@ select! loop → NDJSON → Unix socket → client

This design keeps the automaton's streaming state machine on a dedicated blocking thread (via `spawn_blocking`) while the daemon's main loop remains fully async.

The proxy's response path does not use this bridge. It already holds the bytes
as owned frames handed to it by hyper, so it drives a `StreamRedactor` — an
incremental redactor that keeps between chunks only the bytes a pattern could
still be starting in, and whose output for any chunking is what `redact_bytes`
makes of the whole input — directly from `poll_frame`. No thread and no channel
per response: hyper's polling is the backpressure, and dropping the response
stops the upstream read.

Both paths take their redactor from the same `Arc<RwLock<Arc<Redactor>>>`. A
connection snapshots it once for the child's stdout and stderr, which are
framed against the secrets the child was spawned with. A proxy response
snapshots it per response, because the proxy injects whatever the store holds
at that moment and a token refreshed mid-exec must be redacted on the way
back.

## Wire protocol

Communication uses **NDJSON** (newline-delimited JSON) over the Unix domain socket. Each message is a single JSON line with a `"type"` discriminator field.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Read [README.md](README.md), [ARCHITECTURE.md](ARCHITECTURE.md), and [SECURITY.m

## Tests

- Most logic lives in `cargo test --lib` (432 tests). All hermetic.
- Most logic lives in `cargo test --lib` (458 tests). All hermetic.
- `tests/cli_integration.rs` spawns the real `airlock` binary and runs `daemon start/stop/status`. **These might fail in the Claude Code sandbox**
- Some `client.rs` tests read the process's real stdin. Run `cargo test` with `< /dev/null` or they can hang on an inherited pipe that never closes.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ airlock exec -- curl -s https://run.googleapis.com/v2/projects/my-project/locati

What the daemon does for that invocation: binds a proxy on an ephemeral loopback port, points the tool at it with `HTTPS_PROXY` and `CURL_CA_BUNDLE`, pins the tool's egress to that one port with Seatbelt (macOS) or Landlock (Linux), and tears the whole thing down when the child exits. Per request it checks the host against the route table, the method and path against the rules, attaches the credential, and forwards over a verified TLS connection to a host it has confirmed is publicly routable. Unlisted hosts are simply unreachable — deny by default.

The response is redacted on the way back — header values and body both — so an API that echoes the credential cannot hand it to the agent even via `curl -o file`. Compressed and partial responses are refused rather than forwarded unread: see [SECURITY.md](SECURITY.md#response-redaction).

Caveats worth knowing up front: HTTP/1.1 only (no gRPC or HTTP/2-only endpoints), certificate-pinned clients break under interception, and the agent gets the credential's full API authority on the routed hosts — scope the service account narrowly. Full threat model and residual risks: [SECURITY.md](SECURITY.md#proxy-tools); design rationale: [docs/proxy-tools-design.md](docs/proxy-tools-design.md).

### `[agent]` — for `airlock run`
Expand Down
28 changes: 27 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,35 @@ Egress restriction is the second layer, not the first. It is what makes the tool
| Path contains `.`/`..` segments, `//`, a backslash, or an encoded `/`, `.`, `\` or NUL | `403` — refused rather than normalized, because the upstream's normalization may differ from the matcher's |
| The injected secret's slot is `Stale` | `502` |
| Host resolves to any private, loopback, link-local (incl. `169.254.169.254`), CGNAT, ULA, multicast, documentation or otherwise non-routable address | `502` |
| The upstream answers with a `Content-Encoding` other than `identity`, a transfer coding other than `chunked`, or a partial representation (`206` / `Content-Range`) | `502`, body dropped unread — see [Response redaction](#response-redaction) |

On the way through, every client-supplied copy of the injected header is removed before the credential is attached, as are `Proxy-Authorization`, `Proxy-Connection` and the other hop-by-hop headers. The secret is read from the secret store **per request**, so a background refresh applies to the next one. The header value is assembled into a buffer that is zeroized, never through `format!`, and is marked sensitive.

The request is also rewritten so that the response is something the redactor can read: `Accept-Encoding` is forced to `identity` whatever the tool asked for, and `Range` / `If-Range` are removed.

### Response redaction

Everything the upstream sends back is redacted before it reaches the tool, with the same automaton and the same secret set (raw, base64, URL-encoded and hex variants of *every* declared secret, not just this route's) that the tool's stdout goes through:

- **All response header values** — every one of them, including `Location`, `Set-Cookie` and `WWW-Authenticate`. A value that will not rebuild after replacement is dropped rather than forwarded.
- **The body**, streamed. Nothing is buffered beyond the partial match at the end of a frame, so a multi-gigabyte download costs what a small one costs, and the tool's own read rate is what drives the upstream read. 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 sits outside the header map, so the tool sees the status code with the standard phrase, never the upstream's text.

The redactor is taken per response from the daemon's live handle, not snapshotted when the exec started: a tool runs for minutes, the proxy injects whatever the store holds *now*, and the two generations a refresh leaves behind cover a swap that lands mid-response.

Because a `[REDACTED:name]` placeholder is not the length of the secret it replaced, an upstream `Content-Length` is wrong whenever anything matches — and which it is cannot be known before the body has been read. The proxy therefore drops it for any response that has a body and lets hyper frame the response as chunked (HTTP/1.1 always supports it). A bodiless response — HEAD, `1xx`, `204`, `304` — keeps its length, which describes the representation rather than bytes on the wire, so `curl -I` still reports one.

Three things fail closed rather than being handled, all for the same reason — the redactor reads bytes, not formats, and Airlock adds no decoder to the response path:

- **Compressed responses.** `gzip`, `br`, `zstd` and `deflate` are opaque to a byte-pattern scanner. The request demands `identity`; an upstream that compresses anyway gets a `502` and its body is dropped unread.
- **Unknown transfer codings**, for the same reason.
- **Byte ranges.** A range may begin in the middle of a secret, which would split the pattern across two responses the proxy never sees together while the tool reassembles the plaintext in a file. `Range` and `If-Range` are stripped from the request so the upstream sends the whole representation, and a `206` or `Content-Range` that arrives anyway is refused. Resumed and parallel-chunked downloads therefore do not work through a proxy tool.

There is no configuration that turns any of this off. Redaction on the output path is mandatory in Airlock, and the proxy is an output path.

When a response had anything replaced, the audit line says so: a count of header values on the line written when the headers arrive, and a second line when the body ends carrying the body's count. Counts only — never the matched bytes.

The CONNECT authority is the single source of truth: it selects the route, names the leaf certificate the tool is shown, is the name resolved and dialled, and is the name the upstream certificate is verified against (TLS ≥ 1.2, public roots). The client's SNI is ignored entirely, so `curl --resolve`, `--connect-to`, a forged `Host` and a forged SNI cannot make any two of those disagree. DNS is resolved once and the concrete `SocketAddr` that passed the address check is the one dialled, so rebinding cannot slip between check and use.

Each request is logged to the ring buffer: tool, method, host, path, decision and upstream status. Never a header value, and never the query string — it may carry data.
Expand All @@ -360,7 +386,7 @@ Each request is logged to the ring buffer: tool, method, host, path, decision an
- **Misuse, not leakage.** The agent gets the credential's full API authority on routed hosts — broader than a purpose-built CLI. Mitigate with a narrowly scoped service account first and method/path rules second.
- **Data exfiltration to co-tenants.** Anything the tool can read can be uploaded to an attacker's project on an allowed multi-tenant host (`storage.googleapis.com` serves every GCP customer). The credential cannot.
- **Path rules are a convenience layer, not an authorization system.** They see the path, not the body; a `POST` allowed for one purpose may do another (`:batchUpdate`, GraphQL). IAM is the authority boundary.
- **`-o` and friends bypass the stdout redactor.** Response bodies reach the agent through the tool's stdout, which passes through the Aho-Corasick redactor — so an API that echoes the bearer token back is covered there. A body written to a file (`curl -o`, `--dump-header`, `--trace`) is not. This gap exists for every Airlock tool, but it is more reachable here.
- **An upstream that *transforms* the secret is not caught.** [Response redaction](#response-redaction) closes the `curl -o` / `--dump-header` / `--trace` path for response bytes: what the tool writes to a file was already redacted, so the plaintext credential never exists inside the sandbox. What it does not catch is an upstream that reflects the secret reversed, re-chunked, or encoded in a scheme the redactor does not know — the same limitation the stdout path has always had. It also says nothing about the agent's own request data: a query string or request body the agent chose is forwarded as sent.
- **Linux egress pinning is port-scoped and TCP-only** — see [Linux — Landlock LSM](#linux--landlock-lsm).
- **HTTP/1.1 only.** ALPN offers `http/1.1` and nothing else; gRPC and HTTP/2-only endpoints will not work.
- **Certificate-pinned clients break** under interception. By design.
Expand Down
17 changes: 13 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ airlock exec -- curl -s https://run.googleapis.com/v2/projects/my-project/locati
airlock exec -- curl -s 'https://storage.googleapis.com/storage/v1/b?project=my-project'
```

Four things to know:
Things to know:

- **Do not pass authentication headers.** Airlock attaches the credential
itself. An `-H 'Authorization: ...'` you supply is removed before the request
Expand All @@ -93,9 +93,18 @@ Four things to know:
**not** retry with `--noproxy`, `--insecure`/`-k`, a different port, or a
rewritten URL. Those either fail the same way or fail harder — the sandbox
blocks direct connections and DNS outright. Report the refusal to the user.
- **`-o file` skips redaction.** Output written to a file does not pass through
the redactor, so a response that echoes a credential lands on disk in the
clear. Prefer stdout.
- **Responses are redacted, including to a file.** Header values and body are
redacted inside the daemon, so `-o file`, `--dump-header` and `-D` write
bytes that already have `[REDACTED:NAME]` in place of any credential. Seeing
that in a downloaded file is expected — the API echoed a secret, and Airlock
replaced it.
- **Compressed transfer is not available.** The request always asks the API for
an uncompressed response, so `--compressed` gets you plain bytes, and an API
that compresses anyway produces `502 ... content-encoded`. Nothing to work
around; just read the plain response.
- **Range requests and resumed downloads do not work.** `--range` / `-r` and
`-C -` are stripped, so the API sends the whole resource. A resume attempt
will fail or re-download from the start. Download in one go.

### Check daemon status

Expand Down
Loading