Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
99395df
feat(config): add proxy-tool schema and design for credential-injecti…
paveq Sep 17, 2026
b7368cc
refactor(sandbox): replace requires_network with a three-way NetworkA…
paveq Sep 17, 2026
6083433
feat(proxy): implement the interception proxy runtime
paveq Sep 17, 2026
76b0489
feat(daemon): run proxy tools through the per-exec proxy
paveq Sep 17, 2026
b43d2bd
test(proxy): cover the daemon-owned half of a proxy exec end to end
paveq Sep 17, 2026
8496776
docs: describe proxy tools as a shipped feature
paveq Sep 17, 2026
d3c27ef
fix(proxy): end connections and tunnels with the session, not just th…
paveq Sep 17, 2026
2278525
docs(proxy): drop the stale schema-only decision from the design record
paveq Sep 17, 2026
e96f990
test(proxy): prove the sandbox denies a direct TCP connect without in…
paveq Sep 17, 2026
b6109bb
docs(proxy): record that the Landlock network rule is exercised in Li…
paveq Sep 17, 2026
321ed5d
docs: update lib test count
paveq Sep 17, 2026
ebe2758
feat(proxy): redact upstream responses inside the proxy (#9)
paveq Sep 23, 2026
27a2f51
fix(refresh): swap the redactor in before publishing a refreshed secret
paveq Sep 23, 2026
808a501
fix(proxy): match route rules against the percent-decoded path
paveq Sep 23, 2026
af1eb59
fix(daemon): report startup failures through the readiness pipe
paveq Sep 23, 2026
381bb85
refactor(daemon): share the proxy CA publish step
paveq Sep 23, 2026
cfa79b1
docs: update lib test count
paveq Sep 23, 2026
4f728ee
docs: rewrite proxy tool docs in simple technical English
paveq Sep 23, 2026
ee9c6e8
docs(proxy): explain how allow and deny rules combine
paveq Sep 23, 2026
4c8bb9e
fix(proxy): apply deny rules to trailing-slash and ;param forms
paveq Sep 23, 2026
49312e2
fix(refresh): serialize redactor rebuilds across refresh tasks
paveq Sep 23, 2026
299227d
fix(proxy): refuse CONNECT with 503 when the tunnel limit is reached
paveq Sep 23, 2026
be0df9d
docs: update lib test count
paveq Sep 23, 2026
ef0fe88
fix(proxy): fail CONNECT with 500 when the leaf certificate cannot be…
paveq Sep 23, 2026
d3cea9b
docs(proxy): state that the proxy runs in the daemon
paveq Sep 23, 2026
fec3a9b
refactor(proxy): store the injected header as a HeaderName
paveq Sep 23, 2026
98fc8ae
refactor(proxy): derive reserved env vars from the vars the daemon sets
paveq Sep 23, 2026
b32e5a8
refactor(policy): decide a proxy tool's network access in build_tool_…
paveq Sep 23, 2026
fe82480
refactor(proxy): share one crypto provider and TLS version list
paveq Sep 23, 2026
d77d6b1
refactor(proxy): vet CONNECT in a pure function and refuse in one place
paveq Sep 23, 2026
eecdd29
refactor(proxy): build the daemon-wide proxy state once as ProxyShared
paveq Sep 23, 2026
335e857
refactor(redact): name the live redactor handle RedactorSwap
paveq Sep 23, 2026
825b7de
refactor(refresh): bundle shared refresh state into RefreshShared
paveq Sep 23, 2026
02fec85
refactor(daemon): remove runtime files through one helper
paveq Sep 23, 2026
af5bdca
docs: update lib test count
paveq Sep 23, 2026
9d609d1
refactor(daemon): run both daemon modes through one Daemon start/serve
paveq Sep 23, 2026
f993daf
fix(daemon): snapshot the exec redactor after reading the tool's secrets
paveq Sep 23, 2026
29076ae
fix(daemon): install the SIGTERM handler before signalling readiness
paveq Sep 23, 2026
85ca3fa
refactor(proxy): let each session hold the daemon's ProxyShared
paveq Sep 23, 2026
fc9250d
refactor(daemon): split env resolution and output forwarding out of exec
paveq Sep 23, 2026
ea85fdf
chore: clear the last clippy warning and make pattern_count test-only
paveq Sep 23, 2026
0330f40
refactor(proxy): keep the reserved env var lists with the proxy policy
paveq Sep 23, 2026
3dd1b51
docs: fix rustdoc link warnings
paveq Sep 23, 2026
49eaee2
refactor(daemon): validate and spawn an exec in one fallible start_tool
paveq Sep 23, 2026
3f1e10f
docs: point the proxy design at start_tool and update lib test count
paveq Sep 23, 2026
04b551a
fix(proxy): name the proxy in every refusal the tool sees
paveq Sep 23, 2026
635dd0f
fix(daemon): refuse an exec whose secret reference is not in the store
paveq Sep 23, 2026
ed44f8e
refactor(daemon): let DaemonShared own the child registry outright
paveq Sep 23, 2026
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
143 changes: 124 additions & 19 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ src/
├── secrets.rs Secret<T> wrapper, pluggable secret sources, env clearing
├── refresh.rs Background secret refresh task, exponential-backoff retry
├── policy.rs ToolPolicy / AgentPolicy construction, CWD validation
├── proxy.rs Proxy-tool egress policy: route table, host / path matching
│ ├── ca.rs Per-daemon MITM CA: name constraints, leaf minting and cache
│ └── server.rs Daemon-side interception proxy, one per exec request: CONNECT, vetting, injection
├── redact.rs Aho-Corasick automaton, streaming redaction
├── sandbox.rs SandboxBackend trait, macOS Seatbelt, Linux Landlock
├── exec.rs Binary resolution, env construction, child spawn
Expand Down Expand Up @@ -43,13 +46,16 @@ main()
│ ├─ verify_socket_permissions() ← refuse start if not 0o700
│ │
│ ├─ [daemon start] daemonize()
│ │ ├─ pipe for readiness
│ │ ├─ pipe for readiness (carries success, or the startup error)
│ │ ├─ fork #1: parent waits on pipe
│ │ ├─ setsid()
│ │ ├─ fork #2: intermediate exits
│ │ ├─ redirect stdio → /dev/null
│ │ ├─ chdir("/")
│ │ └─ signal readiness → parent exits
│ │ └─ signal readiness → parent exits 0
│ │ (a failure before that point is written into the pipe
│ │ instead; the parent prints it and exits 1 — the grandchild
│ │ has no stderr, so the pipe is its only voice)
│ │
│ └─ enter_async_runtime()
│ ├─ convert std::UnixListener → tokio::UnixListener
Expand Down Expand Up @@ -96,15 +102,16 @@ main()

### State

The daemon's shared state is wrapped in `Arc` for concurrent access across connection handlers:
The daemon's shared state is one `DaemonShared`, built by `Daemon::start` and held in an `Arc` by every connection handler. The standalone and the embedded (`airlock run`) daemon both run through `Daemon::start` and `Daemon::serve`; they differ only in the PID file, the readiness pipe, and what ends the accept loop (SIGTERM or the `run` session's cancel signal).

| Component | Type | Purpose |
|-----------|------|---------|
| Config | `Arc<Config>` | Parsed `airlock.toml` (immutable after startup) |
| Config | `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 |
| Ring buffer | `Arc<Mutex<RingBuffer>>` | Last 1000 log entries (`VecDeque<LogEntry>`) |
| Child registry | `Arc<Mutex<HashSet<u32>>>` | PIDs of currently running children |
| Redactor | `RedactorSwap` = `Arc<RwLock<Arc<Redactor>>>` | Aho-Corasick automaton for output redaction; refresh tasks swap the inner `Arc`. An exec snapshots it for the child's stdout/stderr right after reading the tool's secrets; a proxy session carries the handle itself and snapshots per response |
| Ring buffer | `RingBuffer` | Last 1000 log entries (`Arc<Mutex<VecDeque<LogEntry>>>`, cloned into refresh tasks and proxy sessions) |
| Child registry | `ChildRegistry` | PIDs of currently running children |
| Proxy | `Option<Arc<ProxyShared>>` | The proxy CA and what every proxy session shares; `None` when no tool is a proxy tool |

### Connection handling

Expand All @@ -126,13 +133,18 @@ Daemon handler:
Static(s) as-is and SecretRef(label) via the in-memory secret store; then
layer the essential pass-through set (PATH, HOME, TERM, USER, TZ, and the
standard LC_* locale family — see exec::ESSENTIAL_VARS)
5. Resolve timeout (per-tool override or global default)
6. Build ToolPolicy (merge sandbox root + global + tool paths)
7. Build SandboxProfile (SBPL on macOS, Landlock on Linux)
8. spawn(ExecRequest { binary, args, work_dir, env, sandbox_profile, timeout })
9. Register child PID in ChildRegistry

10. Concurrent select! loop:
5. Proxy tools only: bind a proxy listener on 127.0.0.1:0, then overlay the
daemon-owned HTTPS_PROXY / NO_PROXY / *_CA_* variables on top of step 4.
The ProxySession is held for the rest of the handler; dropping it aborts
the serve task, so every exit path takes the proxy down with the child.
6. Resolve timeout (per-tool override or global default)
7. Build ToolPolicy (merge sandbox root + global + tool paths), with
network = ProxyOnly(port) for a proxy tool and Full otherwise
8. Build SandboxProfile (SBPL on macOS, Landlock on Linux)
9. spawn(ExecRequest { binary, args, work_dir, env, sandbox_profile, timeout })
10. Register child PID in ChildRegistry

11. Concurrent select! loop:
├── child exit → collect exit code, break
├── stdout chunk → redact → DaemonMessage::Stdout → socket
├── stderr chunk → redact → DaemonMessage::Stderr → socket
Expand All @@ -141,11 +153,74 @@ Daemon handler:
├── stdin timeout (2s)→ auto-close child stdin
└── exec timeout → SIGTERM → 5s → SIGKILL

11. Drain remaining stdout/stderr
12. Send DaemonMessage::Exit { code } or DaemonMessage::Error
13. Unregister child PID
12. Drain remaining stdout/stderr
13. Send DaemonMessage::Exit { code } or DaemonMessage::Error
14. Unregister child PID
```

### Proxy tools

A proxy tool holds no secret. Its only network path is a proxy the daemon
binds for that one execution, which attaches the credential after the request
has left the tool. Rationale and threat model:
[docs/proxy-tools-design.md](docs/proxy-tools-design.md) and
[SECURITY.md](SECURITY.md#proxy-tools).

The CA is generated once per daemon in `Daemon::start` — inside the runtime, after
the fork, so the synchronous-startup invariant is untouched — and shared as an
`Arc` across connections. Its key stays in memory; only the certificate is
written, to `{sandbox_root}/airlock-ca.pem`.

```
child (curl) daemon upstream
│ │ │
│ CONNECT api.example.com:443 │ │
│ Proxy-Authorization: Basic │ │
├─────────────────────────────►│ constant-time token compare │
│ │ port == 443? │
│ │ find_route(host)? │
│◄─────────────────────────────┤ 200, or 407 / 403 │
│ │ │
│ ── TLS handshake ───────────►│ leaf minted for the CONNECT │
│ (client SNI ignored) │ authority, ALPN http/1.1 │
│ │ │
│ GET /v1/things?page=2 │ │
│ Host: api.example.com │ │
├─────────────────────────────►│ Host == authority? │
│ │ no TE+CL, no dup CL? │
│ │ route.permits(method, path)? │
│ │ strip client's copy of the │
│ │ 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 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, 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.

### Redaction pipeline

The redaction pipeline bridges async I/O (tokio) with the synchronous Aho-Corasick streaming API:
Expand All @@ -171,6 +246,24 @@ 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>>>`. An
exec snapshots it once for the child's stdout and stderr, right after it reads
the secrets the child is spawned with. A refresh swaps the redactor before it
publishes a new value, so that snapshot knows every value in the child's
environment; one taken when the connection opened would miss a refresh that
lands before the client sends its request. 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 Expand Up @@ -247,7 +340,11 @@ When a `command` secret declares `refresh = N`, a dedicated tokio task is
spawned in the async runtime to re-run the command every `N` seconds and swap
the in-memory value. The redactor is rebuilt on each successful refresh and
keeps both the new and previous-generation values for one cycle, so output
captured just before the swap is still redacted. On failure, the slot's
captured just before the swap is still redacted. The rebuilt redactor is
swapped in *before* the new value is published to the slot: the proxy reads
the slot per request, so any other order would let an echoing upstream hand
the fresh credential back through a redactor that has never seen it. On
failure, the slot's
health flips to `Stale`, the previous value is retained but the exec path
refuses to inject it, and the task retries with exponential backoff capped
at `refresh_max_backoff` until the upstream recovers.
Expand Down Expand Up @@ -333,7 +430,15 @@ On unsupported platforms, the sandbox is a no-op (only `setpgid` in `pre_exec`),
| `rustix` | Typed safe wrappers for `umask`, `setrlimit`, `prctl`, `test_kill_process` |
| `zeroize` | Backs `Secret<T>` drop semantics (zero on drop) |
| `anyhow` / `thiserror` | Error handling |
| `landlock` | Linux Landlock LSM (Linux-only) |
| `landlock` | Linux Landlock LSM, filesystem and TCP rules (Linux-only) |
| `rustls` / `tokio-rustls` | TLS in both directions of the proxy (ring provider, installed explicitly) |
| `rcgen` | Proxy CA and leaf certificate generation |
| `hyper` / `hyper-util` / `http-body-util` | HTTP/1.1 server and client for the proxy |
| `bytes` | Body buffers on the proxy path |
| `webpki-roots` | Public trust anchors for upstream verification |
| `time` | Certificate validity windows |
| `getrandom` | CSPRNG for the per-exec proxy token |
| `subtle` | Constant-time comparison of the proxy token |

## Build

Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ Read [README.md](README.md), [ARCHITECTURE.md](ARCHITECTURE.md), and [SECURITY.m
## Codebase invariants — do not break these

- **`main()` is synchronous.** No `#[tokio::main]`. Daemonization forks; forking after tokio spawns runtime threads leaves them in undefined state in the child. The entire `synchronous_startup()` must complete before any tokio runtime exists. See [src/daemon.rs:14-19](src/daemon.rs#L14-L19).
- **Double-fork with readiness pipe.** `daemon start` returns to the user only after the grandchild signals it's accepting connections. See [src/daemon.rs:506-572](src/daemon.rs#L506-L572).
- **Double-fork with readiness pipe.** `daemon start` returns only after one of two things: the grandchild signals that it accepts connections, or `daemon start` prints the grandchild's startup error and exits non-zero. Every startup step in `async_main` that can fail runs before the readiness signal and must send its error through the pipe. It must never fail silently. See `daemonize` and `ReadinessPipe` in [src/daemon.rs](src/daemon.rs).
- **Trust boundary is the Unix socket.** The daemon is trusted; the client is not. Socket is mode `0700` and verified post-bind ([src/daemon.rs:416-435](src/daemon.rs#L416-L435)) — refuse to start if filesystem doesn't honor it.
- **Secrets are wrapped in `Secret<T>`** ([src/secrets.rs](src/secrets.rs)) which zeroizes on drop and refuses to `Debug`-print. Never log a `Secret` value, never put one in a `format!`.
- **Pre-exec closures must be async-signal-safe and zero-alloc.** The closure passed to `Command::pre_exec()` in [src/exec.rs](src/exec.rs) — no allocation, no mutex, no `println!`, only raw libc calls. Errors from pre-exec abort the spawn.
- **Redaction is mandatory on the output path.** All bytes leaving the daemon to the client go through the Aho-Corasick redactor in [src/redact.rs](src/redact.rs), including base64 / URL-encoded / hex variants of secrets.

## Tests

- Most logic lives in `cargo test --lib` (223 tests). All hermetic.
- Most logic lives in `cargo test --lib` (479 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 real stdin of the process. Run `cargo test < /dev/null`. Otherwise these tests can hang on an inherited pipe that never closes.

## Top-level docs — keep in sync with the code

Expand Down
Loading
Loading