diff --git a/docs/runtime-and-config-design.md b/docs/runtime-and-config-design.md new file mode 100644 index 0000000..8cd7f55 --- /dev/null +++ b/docs/runtime-and-config-design.md @@ -0,0 +1,851 @@ +# Runtime directory and layered config — design proposal + +**Status:** proposal, blocked on the items marked blocking in [Open +questions and follow-ups](#open-questions-and-follow-ups). 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. When it ships, the user-facing reference will +be [README.md](../README.md) and [SKILL.md](../SKILL.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. +- The approval record, the global config and the runtime directory cannot + be forged or redirected by the agent. + +## Non-goals + +- Backwards compatibility. Airlock is pre-1.0. Old in-project runtime files + are not migrated; users delete them. +- Hot reload. A config change, approved or not, takes effect when the daemon + restarts. +- Protecting against an agent that runs outside `airlock run`. Such an agent + is the user, as far as the OS is concerned. +- Sharing a daemon between worktrees or checkouts of one repo. + +## Overview + +- **Runtime dir:** `$XDG_RUNTIME_DIR/airlock//` on Linux, + `$TMPDIR/airlock//` on macOS. `` is derived from the canonical + project root. 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 when the variable is unset | +|---|---|---| +| Linux | `$XDG_RUNTIME_DIR/airlock` | `${TMPDIR:-/tmp}/airlock-` | +| macOS | `$TMPDIR/airlock` | `confstr(_CS_DARWIN_USER_TEMP_DIR)` + `airlock` | + +Each project gets `//`, where `` is the first 16 hex characters +of the SHA-256 of the canonical project root. Inside it: + +| File | Purpose | +|---|---| +| `airlock.sock` | client socket | +| `airlock.pid` | PID file | +| `airlock-ca.pem` | proxy CA certificate (when a proxy tool is configured) | +| `root` | the canonical project root, as text | + +`root` makes `daemon status` able to say which project a runtime dir belongs +to. It also catches an `` collision: if `root` exists and names a +different project, the daemon refuses to start instead of sharing the +directory. + +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`: a canonical macOS +`$TMPDIR` is about 55 bytes, so the socket path comes to about 92 of the 104 +allowed bytes. + +### Validation + +The daemon creates `` and `/` 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 list` inside `airlock run`) | connect to `airlock.sock` | +| Proxy tools | read `airlock-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)"). + The profile therefore adds `(deny file-write* (subpath ""))` + *after* that allow, so tools keep their scratch space but lose write + access to Airlock's subtree. +- **Linux:** Landlock is allow-only and cannot carve a subtree out of a + grant. `$XDG_RUNTIME_DIR` is not granted by default, and the anchor check + refuses any config or `--allow-write` that would grant it. + +### 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. + +### `--no-project-config` + +This replaces `airlock run --no-config` and `AIRLOCK_SANDBOX_ROOT`. The +empty-config mode those provided is removed. + +- Global flag, valid on `run`, `daemon`, `exec`, `list`, `status` and `logs`. +- 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. +- `airlock run --no-project-config` exports the root to the agent as + `AIRLOCK_ROOT`. `exec` and `list` inside the sandbox use it to find the + daemon, because the agent's working directory may move. An agent that + changes `AIRLOCK_ROOT` reaches only daemons it could reach anyway by + changing directory. + +### 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.]` | Must be defined in exactly one layer. A duplicate is a config error naming both files, so a personal tool cannot silently shadow a team tool, or the reverse. | +| `[secrets.