diff --git a/docs/airlock-v2-design.md b/docs/airlock-v2-design.md new file mode 100644 index 0000000..07e4f74 --- /dev/null +++ b/docs/airlock-v2-design.md @@ -0,0 +1,1653 @@ +# Airlock v2 — design proposal + +One daemon per user with sessions, a runtime directory outside the +project, layered config, and approval of project config. + +**Status:** proposal. The blocking items in [Open questions and +follow-ups](#open-questions-and-follow-ups) are resolved. Nothing here is +implemented yet. Today's behavior is +described in [ARCHITECTURE.md](../ARCHITECTURE.md) and +[SECURITY.md](../SECURITY.md). + +This document is the design record: what changes, why, and which +alternatives were rejected. The user-facing surface (commands and their +options, messages, harness hooks, and the examples) is in +[airlock-v2-ux.md](airlock-v2-ux.md). Its changes U1–U16 to this design are +accepted and incorporated here, except U14, which is +[planned for v2.1](#planned-for-v21). When it ships, the user-facing reference +will be [README.md](../README.md) and [SKILL.md](../SKILL.md). + +The choice of one daemon per user is re-examined against a future central +daemon in [airlock-v2-topology.md](airlock-v2-topology.md). Its proposals +are not folded in yet. The protocol and data model shapes that keep that +path open while implementing this design are in +[airlock-v2-technical-guidance.md](airlock-v2-technical-guidance.md). + +## Problem + +Two separate problems share one fix: the project directory should hold only +config, and no runtime state or trust decisions. + +**Runtime files live in the project.** The daemon writes `airlock.sock`, +`airlock.pid` and `airlock-ca.pem` next to `airlock.toml`, in the sandbox +root. That directory is read-write for the agent and for every tool +([src/policy.rs](../src/policy.rs), `sandbox_root` is always in +`read_write_paths`). So: + +- The agent or any tool can delete or replace the socket, the PID file or + the proxy CA certificate while the daemon runs. Replacing the CA only + breaks proxy tools. It exposes no credential, because the tool→proxy leg + carries none. It is still tampering we should not allow. +- The files show up in `git status`. Every project needs `.gitignore` + entries for them, and they are easy to commit by accident. + +**There is one config file, and it is shared.** `airlock.toml` is checked in +and describes the team's tools. It also has to say where each secret comes +from, and that is personal: one user reads `GH_TOKEN` from 1Password, +another from `gh auth token`. A user who wants an extra tool of their own +has nowhere to put it except the shared file. + +Layering personal config over shared config raises a trust question that +does not exist today: the shared file is written by whoever can land a PR, +and by the agent itself. Today Airlock simply trusts whatever +`airlock.toml` says at daemon start. + +## Goals + +- Socket, PID file and CA certificate live outside the project directory, + in a per-user location the agent and tools cannot write. +- A user can layer personal config over the repo's config: bind secret + sources, add personal tools, adjust the agent sandbox. +- The daemon never acts on a project config file the user has not + approved. That includes a file the agent edited. Editing is allowed and + sometimes useful, but the user reviews the diff and approves it again. +- No sandbox, the agent's or a tool's, can write the approval record, the + global config or the runtime directory, or redirect Airlock to a copy it + wrote. + +## Non-goals + +- Backwards compatibility. Airlock is pre-1.0. Old in-project runtime files + are not migrated; users delete them. +- Applying config changes to a running session on their own. A running + session keeps its config until the user reloads it with + [`session reload`](#reloading-a-session). +- Protecting against an agent that runs outside `airlock run`. Such an agent + is the user, as far as the OS is concerned. +- Protecting against agent-written code that the user runs outside the + sandbox: git hooks, `core.fsmonitor`, `.envrc`, mise hooks, build scripts, + tests. That code runs as the user and can rewrite any anchor, but it can + equally read the user's credentials directly, so it defeats Airlock's core + promise before it reaches the trust store. See [B1](#blocking). +- A network transport. This proposal implements Unix sockets only, but + keeps the protocol free of anything that would rule out TCP later (see + [Transport](#transport)). + +## Overview + +- **One daemon per user**, serving every project through + [sessions](#sessions). A launcher in the user's terminal (`airlock run`, + `airlock session start`) loads and approves a project's config, resolves + its secrets, and registers a session. The agent gets a session token, and + the daemon serves it only that project. +- **Runtime dir:** `/run/user//airlock/` on Linux, the per-user temp + dir from `confstr` on macOS. Neither comes from the environment. The + directory is owned by the user and has mode 0700. +- **Three config layers**, lowest to highest precedence: + 1. **global:** `$XDG_CONFIG_HOME/airlock/airlock.toml` + 2. **repo:** `airlock.toml` in the project root + 3. **local:** `airlock.local.toml` in the project root (gitignored) +- **Trust:** the repo and local files must each match a byte-for-byte copy + the user approved with `airlock trust`. The global file needs no approval. +- **Anchors:** the trust store, the global config and the runtime dir are + refused if they sit where the agent can write. + +## Runtime directory + +### Location + +| Platform | Base | Fallback | +|---|---|---| +| Linux | `/run/user//airlock`, when `/run/user/` exists, is owned by the uid and has mode 0700 | `/tmp/airlock-` | +| macOS | `confstr(_CS_DARWIN_USER_TEMP_DIR)` + `airlock` | none | + +The base ignores `TMPDIR` and `XDG_RUNTIME_DIR`. Both vary between shells of +the same user: agent harnesses, Nix shells and tmux change `TMPDIR`, and +`sudo -u`, cron and some ssh and container setups leave `XDG_RUNTIME_DIR` +unset. A base taken from either would let two shells disagree about where the +daemon lives (see [B6](#blocking)). + +The daemon is per user, so the base holds one set of files: + +| File | Purpose | +|---|---| +| `airlock.sock` | client socket | +| `airlock.pid` | PID file | +| `admin.token` | credential for registering [sessions](#sessions), mode 0600, unreadable from every sandbox | +| `ca/.pem` | a session's proxy CA certificate, when its config has a proxy tool | + +Both bases are per-user and cleared on reboot, which suits a socket and a PID +file. The paths are short enough for `sun_path`: the canonical macOS +per-user temp dir is about 52 bytes, so the socket path comes to about 75 of +the 104 allowed bytes. + +The Linux fallback sits in shared `/tmp` under a predictable name. Another +user who creates it first only makes the validation below fail, so the +daemon refuses to start, and it gains no access. A config that grants write +access to `/tmp` is refused on a system that uses the fallback, by the +[anchor check](#protecting-the-anchors). + +### Validation + +The daemon creates `` and `/ca` with mode 0700. Before using +either, whether it just created it or found it, it checks with `lstat` that: + +- it is a directory, not a symlink, +- it is owned by the effective uid, +- `mode & 0o077 == 0`. + +If any check fails, the daemon refuses to start and names the directory and +the failed check. The existing post-bind socket mode check +([src/daemon.rs](../src/daemon.rs)) stays. + +The runtime base is also one of the [anchors](#protecting-the-anchors), so +it must not fall under any sandbox write grant. + +### Sandbox access + +| Who | Needs | +|---|---| +| Agent (`airlock exec`, `airlock tools list`, `airlock agent check` inside `airlock run`) | connect to `airlock.sock`; never read `admin.token` | +| Proxy tools | read their own session's `ca/.pem`, a new entry in their `read_paths` | +| Ordinary tools | nothing | + +No sandbox gets write access to the runtime dir. + +- **macOS:** the Seatbelt baseline grants read-write to all of `$TMPDIR` + ([src/sandbox.rs](../src/sandbox.rs), "$TMPDIR (per-session scratch)"), + which is normally the same directory as the base. Every profile, agent and + tool, therefore ends with `(deny file-write* (subpath ""))` and + `(deny file-read* (literal "/admin.token"))`. Tools keep their + scratch space but cannot write Airlock's subtree or register sessions. The + denies are the **last** rules in the profile: in SBPL the last matching + rule wins, so any allow emitted after them would re-allow the path. That + includes `agent.filesystem.write`, `extra_write`, `--allow-write` and + built-in profile rules. +- **Linux:** Landlock is allow-only and cannot carve a subtree out of a + grant. Neither `/run/user/` nor `/tmp` is granted by default, and + the anchor check refuses any config or `--allow-write` that would grant + the base. + +### Stale state and migration + +Stale-state cleanup works as today (`check_and_cleanup_stale_state`), but in +the runtime dir. Nothing looks for runtime files in the project directory +any more. Users delete leftover `airlock.sock`, `airlock.pid` and +`airlock-ca.pem`, and the matching `.gitignore` lines. + +## Config layers + +### The three files + +| Layer | Path | Approved? | Who writes it | +|---|---|---|---| +| global | `$XDG_CONFIG_HOME/airlock/airlock.toml` (default `~/.config/airlock/airlock.toml`, on macOS too) | no | the user | +| repo | `/airlock.toml` | yes | the team, PR authors, the agent | +| local | `/airlock.local.toml` | yes | the user, the agent | + +The local file sits in the project directory, where the agent can write, so +it is approved exactly like the repo file. + +### Discovery + +Discovery walks up from the working directory to `$HOME` (inclusive), as +today. The first directory holding an `airlock.toml` **or** an +`airlock.local.toml` owned by the effective uid is the project root. Either +file marks the project, so Airlock can be used in a repo whose team has not +adopted it. The project root is the sandbox root, with the same meaning as +now. + +With no project file found, Airlock fails as today, however much global +config exists. + +### `--config ` + +Exactly that one file: no global layer and no local layer. The project root +is the file's parent directory, as today. The file still has to be approved. + +`--config` is not a global option. It exists only on the commands that +discover config: `run`, `trust`, `config`, `status`, `session start` and +`session reload`. `exec` and `tools list` use their session and read no +files, so a `--config` there would be a silent no-op. + +### `--no-project-config` + +This replaces `airlock run --no-config` and `AIRLOCK_SANDBOX_ROOT`. The +empty-config mode those provided is removed. + +- Valid wherever discovery runs: `run`, `config`, `status`, + `session start` and `session reload`. `exec` and `tools list` do no + discovery, since they use their session. +- Ignores `airlock.toml` and `airlock.local.toml` even if present. +- The project root is the canonical working directory. The config is the + global layer alone, which may be absent (an empty config). +- With no repo or local file, there is nothing to approve. +- The agent finds the daemon through its [session](#sessions), as in every + other mode, so a working directory that moves does not matter. + +### Merge rules + +Each layer is parsed on its own, then the layers are merged, then the +existing validation runs on the merged result. For example, "a proxy tool +must not hold a secret" is checked against the merged tools. + +| Item | Rule | +|---|---| +| `[tools.]` | See [Tools across layers](#tools-across-layers). | +| `[secrets.