From 3504f2c92173c39892810da295d7e4c1d94db6ce Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Wed, 23 Sep 2026 14:37:20 +0300 Subject: [PATCH 1/2] docs: design the runtime dir move, config layering and trust The daemon's socket, PID file and proxy CA certificate sit in the sandbox root, which the agent and every tool can write, and they clutter the repo. Separately, a single checked-in airlock.toml forces personal choices like where GH_TOKEN comes from into the shared file. The proposal moves runtime files to a per-user 0700 directory, layers a global and a gitignored local config over the repo file, and gates the repo and local files behind byte-exact approval (`airlock trust`) with a diff on refusal. It records each design choice with the rejected alternatives, and compares the trust model with `mise trust`. --- docs/runtime-and-config-design.md | 614 ++++++++++++++++++++++++++++++ 1 file changed, 614 insertions(+) create mode 100644 docs/runtime-and-config-design.md diff --git a/docs/runtime-and-config-design.md b/docs/runtime-and-config-design.md new file mode 100644 index 0000000..dc141b9 --- /dev/null +++ b/docs/runtime-and-config-design.md @@ -0,0 +1,614 @@ +# Runtime directory and layered config — design proposal + +**Status:** proposal. 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.