diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07d06ae..3b058ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: name: Test runs-on: ubuntu-latest needs: check + # A hung test otherwise holds the runner for GitHub's 6-hour default. + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install system dependencies @@ -39,6 +41,7 @@ jobs: name: Test (macOS) runs-on: macos-latest needs: check + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 875ab1b..2cd1a09 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,163 +1,207 @@ # Architecture -Airlock is a single Rust binary that operates in two modes: **daemon** (long-running, holds secrets, spawns tools) and **client** (short-lived, connects to the daemon, proxies I/O). Both modes are compiled into the same binary and selected by the CLI subcommand. +Airlock is a single Rust binary that operates in two modes: **daemon** (long-running, one per user, holds every session's secrets, spawns tools) and **client** (short-lived: a **launcher** that registers a session, or `exec`/`tools list`/`agent check` that use one). Both modes are compiled into the same binary and selected by the CLI subcommand. + +See [docs/airlock-v2-design.md](docs/airlock-v2-design.md) for why this shape was chosen over a daemon per project; this document describes what shipped. ## Module map ``` src/ -├── main.rs CLI entry point and command dispatch -├── config.rs Config discovery, TOML parsing, path resolution -├── 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 -├── run.rs Agent process orchestrator for `airlock run` -├── daemon.rs Daemonization, accept loop, exec orchestration -├── client.rs Socket connection, stdio proxying, NDJSON streaming -├── protocol.rs ClientMessage / DaemonMessage wire types -└── lib.rs Module declarations +├── main.rs CLI entry point and command dispatch +├── runtime_dir.rs Per-user runtime base: location, validation, derived paths +├── anchors.rs Trust store / global config / tool-state base: XDG resolution, validation against write grants +├── process_tree.rs Peer PID/uid of a Unix connection, parent-chain walk, token-binding check +├── layers.rs Config layer discovery (global/parent/repo/local), merge rules, provenance +├── config.rs Raw TOML types, per-file parsing and validation, wire (daemon-side) config resolution +├── kits.rs Agent kits: built-in/user-defined definitions, validation, pure expansion (launcher-only) +├── trust.rs Trust store, approval state, escaped diff rendering, prompts +├── protocol.rs Wire types: Hello, Request/Auth, two request families, ErrorKind, exit-code mapping +├── session.rs In-daemon session state: policy, secrets, redactor, binding, lease/TTL +├── secrets.rs Secret wrapper, pluggable secret sources, filtered-PATH-aware command resolution +├── refresh.rs Background secret refresh task, exponential-backoff retry +├── policy.rs ToolPolicy / AgentPolicy construction, CWD validation, write-grant collection +├── 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; the daemon-wide last-pass redactor +├── sandbox.rs SandboxBackend trait, macOS Seatbelt, Linux Landlock +├── exec.rs Filtered PATH, binary resolution and location check, env construction, child spawn +├── run.rs `airlock run`: launcher + agent sandbox orchestration, tool shims +├── relay.rs `airlock run`'s pty between the agent and the user's terminal +│ └── filter.rs Escape-sequence filter on the relayed output (clipboard, iTerm2 tmux integration) +├── launcher.rs Shared launcher steps: discover, approve, resolve secrets, `Register`/`Reload` +├── daemon.rs Daemonization, accept loop, per-connection dispatch across sessions +├── agent.rs `airlock agent check` / `agent hook` +├── inspect.rs `airlock config` / `airlock status` rendering +├── service.rs `airlock daemon install`/`uninstall` (launchd / systemd user unit) +├── client.rs Socket connection, stdio proxying, NDJSON streaming +└── lib.rs Module declarations ``` ## Startup sequence -The `main()` function is intentionally **synchronous** — no `#[tokio::main]`, no async runtime. This is critical because daemonization requires forking, and forking after tokio spawns background threads leaves those threads in an undefined state in the child. +The `main()` function is intentionally **synchronous** — no `#[tokio::main]`, no async runtime. This is critical because daemonization requires forking, and forking after tokio spawns background threads leaves those threads in an undefined state in the child. See [src/daemon.rs:14-19](src/daemon.rs#L14-L19). ``` main() │ ├─ parse CLI args (clap) │ - ├─ "daemon start" or "daemon run" + ├─ "daemon start [--foreground|--automatic]" │ │ │ └─ synchronous_startup() ← all pre-fork work │ ├─ harden_process() ← RLIMIT_CORE=0, PR_SET_DUMPABLE=0 (Linux) - │ ├─ config::load_config() ← O_NOFOLLOW + fstat; refuses $HOME root - │ ├─ cleanup stale PID/socket - │ ├─ secrets::collect_secrets() - │ ├─ secrets::clear_secret_env_vars() - │ ├─ Redactor::new() ← build Aho-Corasick automaton + │ ├─ runtime_dir::locate() + create_and_validate() ← per-user base, outside every project + │ ├─ acquire_startup_lock(airlock.lock) ← exclusive flock, held for the daemon's life; + │ │ a losing concurrent `daemon start` gets + │ │ StartInProgress and exits 75, not an error + │ ├─ cleanup stale PID/socket (in the runtime dir, not the project; safe to treat a + │ │ socket with no PID file as stale now, since the lock rules out a concurrent starter) + │ ├─ write admin.token (32 random bytes, mode 0600) │ ├─ umask(0o077) + UnixListener::bind() + restore umask │ ├─ verify_socket_permissions() ← refuse start if not 0o700 │ │ - │ ├─ [daemon start] daemonize() - │ │ ├─ 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 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) + │ ├─ [daemon start] daemonize() ← same double fork + readiness pipe as v1 │ │ │ └─ enter_async_runtime() │ ├─ convert std::UnixListener → tokio::UnixListener │ ├─ write PID file │ ├─ install SIGTERM handler - │ └─ accept loop - │ - ├─ "daemon stop" - │ └─ read PID file, send SIGTERM, poll for exit (10s) + │ └─ accept loop, no sessions yet │ - ├─ "exec -- [args...]" - │ └─ client::run_exec() - │ ├─ connect to Unix socket - │ ├─ send ClientMessage::Exec - │ ├─ proxy stdin → socket, socket → stdout/stderr - │ └─ exit with child's exit code - │ - ├─ "run [--no-daemon] -- [args...]" + ├─ "run [--profile NAME] [--kit NAME]... [-- CMD...]" │ └─ run::run_agent() - │ ├─ load config (discover or explicit --config path) - │ ├─ [default] daemon::synchronous_startup() ← embedded daemon - │ │ └─ same startup sequence as "daemon start" but in-process - │ ├─ build_agent_policy() ← AgentPolicy for the harness sandbox - │ ├─ sandbox::build_profile() ← Seatbelt (macOS) / Landlock (Linux) - │ ├─ construct clean env (passthrough_env + [agent.env] entries) - │ ├─ spawn agent with sandbox applied in pre_exec + │ ├─ launcher::register_session() ← discover, approve, resolve secrets (see below); + │ │ also validates and expands this run's active kits (kits::expand_all) and folds + │ │ their write dirs/files into write_grants before the anchor/PATH checks run + │ ├─ build_agent_policy() + sandbox::build_profile(), kit read/write paths added on top + │ ├─ build_agent_env(), kit env applied on top (overriding passthrough/[agent.env]) + │ ├─ spawn agent with AIRLOCK_ADDR / AIRLOCK_SESSION set, sandbox applied in pre_exec + │ ├─ hold the `Register` connection open as the session's lease │ ├─ forward SIGTERM / SIGHUP to agent; enforce optional timeout - │ └─ [default] stop embedded daemon on agent exit - │ - ├─ "status" - │ └─ probe socket liveness (ground truth); PID file only enriches output + │ └─ on agent exit, close the lease connection (daemon revokes the session) │ - ├─ "list" - │ └─ parse config, print each tool's env (static + secret-backed) + ├─ "session start" / "session reload" / "trust" / "config" / "status" + │ └─ launcher-family commands; same discover/approve path as `run`, different endpoint │ - ├─ "logs" - │ └─ connect to socket, send ClientMessage::Logs, print entries + ├─ "exec -- [args...]" / "tools list" / "agent check" + │ └─ client::run_exec() / ... + │ ├─ read AIRLOCK_ADDR / AIRLOCK_SESSION only — no discovery, no admin.token + │ ├─ connect to the socket, send a session-family Request + │ └─ proxy stdin → socket, socket → stdout/stderr; exit with the tool's status or 125/126/127 │ - └─ "init" - └─ write default airlock.toml template + └─ "init" / "daemon stop/restart/logs/install/uninstall" + └─ as the names suggest; see docs/airlock-v2-ux.md for the full surface ``` -## Daemon internals +## Config layering and registration + +A **launcher** (`airlock run`, `session start`, `session reload`, and `trust`/`config`/`status` for inspection) is the only code that reads project config. The daemon never does. The steps, shared by `src/launcher.rs`: + +1. **Discover.** Walk up from the working directory to `$HOME` for the first directory holding `airlock.toml` or `airlock.local.toml` ([src/layers.rs](src/layers.rs) `load_layers`), then on up from that root for parent directories whose config sets `cascade = true` (stopping below `$HOME`, or at one that sets `inherit = false`, and not at all if the project sets `inherit = false`), or use exactly the `--config` file, or (`--no-project-config`) skip project discovery and use only the global layer. Each file is read once (`read_config_securely`): the same bytes are hashed for the trust store and parsed for merging. +2. **Approve.** Compare each parent/repo/local file's hash against the trust store, in the slot of the directory the file sits in ([src/trust.rs](src/trust.rs)). An unapproved file's diff (or full contents, for a first approval) is shown, escaped against ANSI/bidi/zero-width tricks, and the launcher asks `Trust this version and continue? [y/N]` on a terminal, or refuses on a non-terminal. The global layer needs no approval — it is protected by the [anchor checks](#protecting-the-anchors) instead. +3. **Merge.** `src/layers.rs::merge` folds the layers: the global file is the first base, each parent's own files merge onto it and become the next base, and the project's files merge last, so a parent is treated exactly like the global layer by everything below it. At each step: lists union, `agent.env` is per-key highest-wins, a project tool replaces a same-named global one (visibly), a local tool replaces a repo one only with `override = true`, and a secret label crosses from repo to local to global only through an explicit local binding (`from = "global"`, or `from = "parent"` for a parent's binding). Validation (undeclared secret refs, proxy tool shape, …) runs on the merged result, so a user is never asked to approve a file that would fail anyway. +4. **Resolve secrets.** Every `source = "env"` reads the launcher's own environment; every `source = "command"` is spawned by the launcher, not the daemon, on the trusted side. Command argv\[0\] and every tool binary are resolved against a **filtered `PATH`**: the launcher's `PATH` with relative entries and anything inside the project root or a write grant dropped, and the resolved path re-checked after canonicalization. This closes the gap where an approved `airlock.toml` names a command, but a planted binary on a writable `PATH` entry is what actually runs. +5. **Register.** The launcher sends a `Register` request over the admin channel: the project root, the merged config (as `RawConfig`, the same `deny_unknown_fields` TOML types the files parse into — a field the daemon doesn't recognize is rejected, not ignored), the layer hashes, the resolved secret values, an environment snapshot and the filtered `PATH`, a session name, and whether the agent runs in Airlock's own sandbox or an external one. The daemon answers with a session id and a 32-byte token; the launcher drops its `Secret` values once this succeeds. + +`session reload` repeats steps 1–4 for one or more running sessions and sends `Reload`; the daemon compiles a new policy and swaps it into the session in one step. The session keeps its id and token. The agent's own sandbox settings (`[agent]`) cannot change on a running process, so a reload that touches them is applied to everything else and reported as "agent settings changed; restart to apply". -### State +### Kits -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). +Kits ([src/kits.rs](src/kits.rs)) add a language toolchain's cache access to `airlock run`'s agent sandbox — a layer orthogonal to the harness profile. They are purely a `run`-time, launcher-side concept: `layers::merge` validates and resolves `[kits.*]`/`agent.kits` but **never puts the `[kits.*]` option tables on the wire** (`agent.kits`, the plain name list, does ride along on `RawAgentConfig`, unused by the daemon, so it and the resolved option tables both feed the agent hash `session reload` checks). `kits::expand_all` is the one pure function that turns an active kit list into concrete paths and env — called from `launcher::prepare` (for `run`) and from `inspect::config_cmd`'s own display, never from `layers::merge`, which reads no environment by design. A kit's write dirs/files are folded into `write_grants` before the anchor and filtered-`PATH` checks in step 4 above, so no separate check was needed for them. -| Component | Type | Purpose | -|-----------|------|---------| -| 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 | `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 | +### The agent's terminal -### Connection handling +When any of `airlock run`'s stdio is a terminal and `[agent.terminal] relay` is on (the default), the agent gets a pty of its own instead ([src/relay.rs](src/relay.rs)): `Relay::open` allocates it before the sandbox profile is built (the profile grants exactly its slave), `spawn_agent` hands the slave to the agent as stdio and, in `pre_exec` before the sandbox applies, `setsid` + `TIOCSCTTY` make it the agent's controlling terminal. `Relay::start` puts the real terminal in raw mode and starts one relay thread that `poll`s the master and stdin, passing the agent's output through `relay::filter::Filter` unless `filter = false` (a streaming parser; the thread hands it back, mid-sequence state and all, for the final forward); `signal_loop` passes SIGWINCH on through `Relay::resize`. When the agent exits, `Relay::finish` stops the thread, forwards output still in flight, and reads the terminal's input up to the answer to its own status query (`ESC [5n`) before restoring the terminal and closing the master. Why each step exists is in [SECURITY.md](SECURITY.md#the-agents-terminal). -Each accepted connection is spawned as a `tokio::spawn(handle_connection(...))` task. The handler reads one `ClientMessage` from the socket: +### Tool shims -- **`Exec`** → full tool execution flow (see below) -- **`Logs`** → returns ring buffer contents as `DaemonMessage::LogsResponse` +`shim = true` on a tool is, like kits, a `run`-time concept the daemon only reports. After registering, `run_agent` ([src/run.rs](src/run.rs)) writes one `sh` script per shimmed tool into `/shims/`, each `exec`ing this very binary (`current_exe`, canonicalized) as `airlock exec -- "$@"`. The runtime dir is per-boot, `0700`, and writable by no agent or tool sandbox, so the scripts stay as written; the agent gets read on that one directory, the same way a proxy tool gets read on its CA file next to it. `run` puts the directory first on the agent's `PATH`, grants read on it and on the binary, and removes it when the agent exits; the daemon's `release_session` removes it too, next to the CA file, so a launcher killed before its own cleanup leaves nothing behind once its lease closes. The daemon resolves the real tool on the session's filtered `PATH`, computed from the launcher's own `PATH` before the shim directory existed, so a shim can never resolve to itself. The shimmed names are part of the agent hash, since the scripts are written once at launch. `tools list` reports a tool as shimmed only for a session registered with Airlock's own sandbox (`SandboxKind::Airlock`), the only kind `run` creates. -### Exec flow (per-connection) +## Sessions + +One daemon serves every project a user has. A **session** binds a client to one project root, its approved config, and the secrets resolved for it — see [docs/airlock-v2-design.md#sessions](docs/airlock-v2-design.md#sessions) for the full design. + +| Component | Type (indicative) | Purpose | +|---|---|---| +| Sessions | `DashMap>` or equivalent | Every registered session; the token resolves to exactly one `Arc` | +| `Session` | struct | id, token, root, `RwLock>` (compiled tools/secrets/proxy routes, swapped whole by `Reload`), process-tree binding, lease-or-TTL, exec counter | +| Admin credential | `admin.token`, mode 0600 in the runtime dir | Required by `Register`, `Reload`, `session list/revoke/renew`, `tools list --session`, `status`, `daemon logs/stop/restart`. No sandbox can read it. | +| Global redactor | `Arc>>` built from every live session's secrets, current and pre-refresh values | A last-pass safety net: after a session's own redactor, output also passes this one, so a bug that put another session's secret in the wrong place still gets masked | + +**Request handling.** A connection first exchanges a version `Hello`, then sends one `Request { auth, body }`. `auth` is `{"kind": "session", "token": ...}` or `{"kind": "admin", "token": ...}`; the listener resolves it to a `Principal` (peer uid check, and for a session token the [token-binding](#token-binding) check) before any handler runs, and refuses a request whose family doesn't match its auth kind. `body` is one of two enums: + +- **Session family** — `Exec { tool, args, cwd }`, `Stdin`/`StdinEof`, `List`, `Check`. Resolved through the session's own `Arc`; nothing here ever consults a global map keyed by secret label. +- **Admin family** — `Register`, `Reload`, `ListSessions`, `Revoke`, `Renew`, `Tools { session }`, `Logs`, `Stop`. Gated on `admin.token`. + +**Error kinds.** `Error { kind, message }`, where `kind` is an enum — `NoSession`, `SessionEnded`, `SessionExpired`, `OutsideProcessTree`, `DaemonUnreachable`, `UnknownTool`, `BinaryUnusable`, `StaleSecret`, `OutsideRoot`, `Busy`, `IncompatibleProtocol`, `Malformed`, `Unauthorized`, `Internal` — mapped to exit codes in one place: `UnknownTool` → 127, `BinaryUnusable` → 126, everything else → 125. See [docs/airlock-v2-technical-guidance.md](docs/airlock-v2-technical-guidance.md) for why kinds are an enum rather than formatted strings. + +### Exec flow (per-connection, within a session) ``` -Client sends: {"type":"exec","tool":"gh","args":["repo","list"],"cwd":"/home/user/project"} +Client sends: {"auth":{"kind":"session","token":"airlock_7f3a9c_..."}, + "body":{"type":"exec","tool":"gh","args":["repo","list"],"cwd":"/home/user/project"}} Daemon handler: - 1. Validate tool exists in config - 2. Validate CWD is within sandbox root - 3. Resolve binary: walk PATH for "gh" → "/usr/bin/gh" - 4. Build child env: walk tool.env in declared (alphabetical) order, resolving - 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. 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 - ├── client message → stdin data → child stdin pipe - │ → stdin_eof → close child stdin - ├── stdin timeout (2s)→ auto-close child stdin - └── exec timeout → SIGTERM → 5s → SIGKILL - -12. Drain remaining stdout/stderr -13. Send DaemonMessage::Exit { code } or DaemonMessage::Error -14. Unregister child PID + 1. Resolve the token to a Session (constant-time compare); check the caller + descends from the session's anchor process (token binding, see below). + 2. Validate tool exists in the session's current SessionPolicy; validate CWD + is within the session's root. + 3. Resolve binary on the session's filtered PATH; refuse if it resolves + inside the root or a write grant (same check the launcher ran at Register). + 4. Build child env: tool.env in alphabetical order (a `BTreeMap`, not + declaration order), Static(s) as-is and SecretRef(label) via the + session's secret store; then the essential pass-through set (PATH, + HOME, TERM, USER, TZ, LC_*). + 5. Proxy tools only: bind a loopback proxy listener for this exec, overlay + the daemon-owned HTTPS_PROXY / CA variables. + 6. Resolve timeout and `access` level (`tools..access`, else the + config's top-level default, else `ToolAccess::Default`); build ToolPolicy + and SandboxProfile (SBPL / Landlock). + 7. spawn, register child PID, run the concurrent I/O loop (stdout/stderr + redacted through the session's own redactor, then the global one; stdin; + timeout → SIGTERM → SIGKILL), same as v1. + 8. Send Exit{code} or Error{kind, message}. ``` +A per-session cap on concurrent `exec`s (16; the 17th is refused with `Busy`) keeps one agent from starving the others sharing the daemon. + +### Token binding + +A session token sits in every process the harness starts, and a same-uid process outside any sandbox can read another process's environment (`ps eww` on macOS, `/proc//environ` on Linux). So the daemon binds each session to a **process tree**, not just a token: on every connection it takes the peer PID off the socket (`LOCAL_PEERPID` / `SO_PEERCRED`, [src/process_tree.rs](src/process_tree.rs)) and walks the parent chain, checking it against the session's recorded anchor process (PID + start time, so a reused PID can't match). For `run` the anchor is the launcher, alive exactly as long as the lease; for `session start` it's the shell that ran it. A copied token is then useless outside the tree it was issued to. + +### Lifetime + +| Started by | Ends when | +|---|---| +| `airlock run` | its **lease** closes — the `Register` connection stays open for the life of the launcher; the daemon holds a task that waits for EOF (close-on-exec, so the agent never inherits it) and revokes the session on close. The kernel closes the descriptor however the launcher dies, so nothing can leak a session past a dead launcher. | +| `airlock session start` | its **TTL** (default 12h, `session renew` restarts it without changing the token) runs out, or it's revoked | + +### Lifecycle + +Two modes, same daemon binary: **automatic** (the first launcher that finds none starts one with the existing double-fork/readiness-pipe sequence; it exits after a grace period with no sessions) and **service** (`airlock daemon install` writes a launchd agent / systemd user unit running `daemon start --foreground`; never idle-exits). Because every session's secrets, environment snapshot and filtered `PATH` travel with `Register`, the daemon's own environment is irrelevant in either mode. + +**Version handshake.** Every connection starts with `Hello{protocol, version}` / `Hello{protocol, version, pid, mode, sessions}`. An automatic daemon outlives the binary that started it, so a launcher that finds an idle daemon of another version restarts it; a busy one keeps serving with a note, unless the protocol version is incompatible, in which case the launcher refuses and points to `daemon restart`. + +## Protecting the anchors + +Three locations decide what the daemon trusts and how clients reach it, each resolved from XDG variables and then validated ([src/anchors.rs](src/anchors.rs)): + +| Anchor | Default | Approved? | +|---|---|---| +| runtime base | see [runtime_dir.rs](src/runtime_dir.rs); never taken from the environment | n/a — holds the socket, PID file, `admin.token`, proxy CAs | +| trust store | `$XDG_STATE_HOME/airlock/trust` | holds the approved copies | +| global config | `$XDG_CONFIG_HOME/airlock/airlock.toml` | no — protected by this check instead | + +Each is checked before use: owned by the effective uid, mode 0700 (or non-group/other-writable for the global file), not a symlink, outside the project root, and outside every sandbox write grant from every layer — including unapproved ones, and including `--allow-write` and built-in profile paths. The one exception is macOS `$TMPDIR`, which the runtime base may sit under, because the Seatbelt deny rule (below) carves it back out. The same check runs the other way at config load: a write grant that covers an anchor is a config error. This closes the path where a repo's `mise.toml` or `.envrc` redirects `XDG_STATE_HOME` into the project on `cd` — the redirect lands somewhere the agent still can't write. + +The runtime base sits in a directory the OS sweeps by age (macOS `dirhelper` deletes files under the per-user temp dir untouched for 3 days; `systemd-tmpfiles` ages out the Linux `/tmp` fallback). Since the daemon writes `admin.token` and the PID file only once, at startup, the serve loop refreshes the timestamps of everything under the base every hour (`RuntimeDir::touch_tree`). Without that, a daemon running longer than 3 days would lose its admin token and could only be killed. + +## Daemon internals (within one session) + +Everything in this section is unchanged in substance from a single-tenant daemon; it now runs per-session rather than per-process. + ### Proxy tools A proxy tool holds no secret. Its only network path is a proxy the daemon @@ -169,7 +213,8 @@ has left the tool. Rationale and threat model: 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`. +written, to `/ca/.pem`, readable only by that +session's proxy tools. ``` child (curl) daemon upstream @@ -221,22 +266,20 @@ 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. +Running each session's proxy in its own process, rather than in the shared daemon, is tracked as [F10](docs/airlock-v2-design.md#follow-ups) — today the proxy is the largest piece of untrusted-input parsing (hyper, rustls) in a process that now holds every project's secrets, not just one. + ### Redaction pipeline -The redaction pipeline bridges async I/O (tokio) with the synchronous Aho-Corasick streaming API: +Both a tool's output and the proxy's responses go through `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. ``` -tokio async reader task - │ - │ reads child stdout/stderr in chunks +tokio task per stream (stdout, stderr) + │ reads child output in chunks ▼ -std::sync::mpsc::Sender - │ +StreamRedactor (session's redactor) ▼ -spawn_blocking(redact_stream) - │ ChannelReader (impl Read over mpsc::Receiver) - │ → Aho-Corasick try_stream_replace_all - │ → ChannelWriter (impl Write over tokio mpsc::Sender) +StreamRedactor (daemon-wide global redactor) + │ both flushed with finish() at EOF ▼ tokio::sync::mpsc::Receiver │ @@ -244,136 +287,74 @@ tokio::sync::mpsc::Receiver 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. +Chaining two streams matters for the global pass. The session pass emits its output in chunks of its own choosing, so a global pass that redacted each chunk on its own would miss another session's secret split across two of them. -## Wire protocol +The proxy's response path holds the bytes as owned frames handed to it by hyper, so it drives its `StreamRedactor` directly from `poll_frame`. No task and no channel per response: hyper's polling is the backpressure, and dropping the response stops the upstream read. -Communication uses **NDJSON** (newline-delimited JSON) over the Unix domain socket. Each message is a single JSON line with a `"type"` discriminator field. +Both paths take their redactor from the session's own `Arc>>`, then from the daemon-wide last-pass redactor described under [Sessions](#sessions). An exec snapshots its session's redactor once, 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. A proxy response snapshots 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. -### Client → Daemon +## Wire protocol + +Communication uses **NDJSON** (newline-delimited JSON) over the Unix domain socket. A connection opens with a `Hello` exchange, then exactly one `Request`; an `Exec` request continues with `Stdin`/`StdinEof` lines until `Exit`. ```json -{"type":"exec","tool":"gh","args":["repo","list"],"cwd":"/home/user/project"} +{"protocol":2,"version":"0.6.0"} +{"protocol":2,"version":"0.6.0","pid":48211,"mode":"automatic","sessions":2} + +{"auth":{"kind":"session","token":"airlock_7f3a9c_..."}, + "body":{"type":"exec","tool":"gh","args":["repo","list"],"cwd":"/home/user/project"}} {"type":"stdin","data":"input line\n"} {"type":"stdin_eof"} -{"type":"logs"} -``` - -### Daemon → Client -```json {"type":"stdout","data":"output line\n"} {"type":"stderr","data":"error line\n"} {"type":"exit","code":0} -{"type":"error","message":"unknown tool: bad-tool"} -{"type":"logs_response","entries":[{"timestamp":"...","message":"..."}]} +{"type":"error","kind":"unknown_tool","message":"no tool named \"bad-tool\" in this session"} ``` -The `ClientMessage` and `DaemonMessage` enums are separate Rust types, ensuring at compile time that each side of the socket only sends one family and receives the other. - -## Config discovery - -`airlock.toml` is found by walking upward from the current working directory toward `$HOME`: - -1. Check CWD for `airlock.toml` -2. Check parent directory -3. Continue until reaching `$HOME` (inclusive) -4. Stop at the first file found - -**Ownership check**: The file must be owned by the current effective UID. This prevents a shared `/tmp` directory from being used to inject a crafted config. - -**TOCTOU-safe read**: Discovery and load both go through an `O_NOFOLLOW` open followed by `fstat` on the fd. Symlinks, non-regular files, and files whose UID changes between the walk and the read are rejected. A 1 MiB size cap bounds allocation. - -**Home-directory refusal**: If the discovered config sits directly at `$HOME`, `load_config` returns `HomeRootNotAllowed` unless the config contains `allow_home_root = true`. Without the opt-in, a stray `airlock.toml` in the home directory would otherwise make the entire home directory the sandbox root. - -### Path resolution - -Paths in the config are resolved relative to the **sandbox root** (the directory containing `airlock.toml`): - -| Input | Resolution | -|-------|-----------| -| `~/foo` | `$HOME/foo` | -| `relative/path` | `/relative/path` | -| `/absolute/path` | `/absolute/path` (unchanged) | - -### Derived paths - -From the sandbox root, two paths are derived automatically: - -- **Socket**: `/airlock.sock` -- **PID file**: `/airlock.pid` +Session-family lines are capped at 1 MiB, as in v1. Admin-family lines (chiefly `Register`, which carries a config, secret values, an environment snapshot and a filtered `PATH`) have their own, larger cap, since they come only from a trusted launcher. Request and reply types are separate Rust enums per family, so the compiler enforces which side sends what; see [docs/airlock-v2-technical-guidance.md](docs/airlock-v2-technical-guidance.md) for why the two-family split and the error-kind enum are worth keeping even though v2 has only one transport. ## Secret sources `[secrets.