Skip to content

docs: design Airlock v2 (per-user daemon, sessions, layered config, trust) - #14

Open
paveq wants to merge 10 commits into
mainfrom
docs/runtime-and-config-design
Open

paveq wants to merge 10 commits into
mainfrom
docs/runtime-and-config-design

Conversation

@paveq

@paveq paveq commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Design proposal only, no code. Five documents:

  • docs/airlock-v2-design.md: the design record: what changes, why, and the alternatives rejected. Blocking items B1–B8 and Q1 are resolved. Follow-ups F1–F3, F6, F7 and F9–F11 remain.
  • docs/airlock-v2-ux.md: the user-facing surface: commands and their options, journeys, harness hooks, messages, examples. Its changes U1–U16 are folded into the design; U14 is deferred to v2.1.
  • docs/airlock-v2-questions.md: the questions a developer meeting v2 would ask, with coverage marks and the decisions on each gap.
  • docs/airlock-v2-topology.md: the direction after v2, a central daemon; what it requires; four local topologies compared against it; proposed changes, not folded in.
  • docs/airlock-v2-technical-guidance.md: for the implementer: protocol and data model shapes to use now, and additions postponed to v2.1 or later.

What it proposes

  • One daemon per user, with sessions.
    • A launcher in the user's terminal (airlock run, airlock session start) discovers and approves the project's config, resolves its secrets, and registers a session.
    • The agent gets AIRLOCK_ADDR and AIRLOCK_SESSION. exec, tools list and agent check are served only with a session token. Only a process holding admin.token can register a session, and no sandbox can read that file.
    • A session is scoped to the project it was started in. A run session ends when the launcher's connection closes (the lease). A session start session ends at its TTL, 12h by default, which session renew restarts. Any session ends on revoke or when the daemon stops.
    • Session tokens are bound to the launcher's process tree, so a copied token is useless outside it.
    • The daemon starts automatically and exits when idle by default. airlock daemon install sets it up as an optional launchd/systemd service. A version handshake handles a daemon that outlives its binary.
    • airlock run no longer embeds a daemon, so several agents can run in one project.
  • Session isolation in a shared process. The session handle is the only path to secrets. No process environment is read after startup, enforced by a clippy lint. A global last-pass redactor covers every live session's secrets. Each session has a cap on concurrent execs. Moving the proxy into its own process per session is a follow-up (F10).
  • Transport kept open. AIRLOCK_ADDR is a URI (unix:// for now). Authorization never relies on uid alone, and registration stays on the daemon's host.
  • Runtime dir. airlock.sock, airlock.pid, admin.token and each session's CA certificate move out of the project: /run/user/<uid>/airlock/ (fallback /tmp/airlock-<uid>) on Linux, the per-user temp dir from confstr on macOS. Neither is read from the environment. No sandbox can write there; on macOS the Seatbelt denies are the last rules in every profile.
  • Config layers: global ~/.config/airlock/airlock.toml < repo airlock.toml < local airlock.local.toml. A project tool replaces a global one, visibly; repo against local is an error unless the local tool says override = true. Repo items use only labels the repo declares; the local file binds them, and from = "global" reuses a global binding. airlock init --local writes the stubs. {tool_state} keeps tool state outside the project.
  • Trust. The repo, local and --config files must match a byte-exact copy approved with airlock trust, or at the prompt airlock run shows on a terminal; on a non-terminal it refuses. Diffs are escaped against ANSI and bidi tricks. trust --yes and --expect-sha256 serve scripts and CI. Running sessions keep their config until airlock session reload.
  • What approval does not cover. The daemon builds a filtered PATH without relative or agent-writable entries, and refuses tool binaries and secret commands that resolve into the project or a write grant.
  • Anchors. The trust store and the global config honor XDG variables but are refused if they sit inside the project or under any sandbox write grant.
  • Agent integration. airlock agent check verifies the session and self-tests the sandbox, with the paths to probe supplied by the daemon. airlock agent hook claude-code answers Claude Code's SessionStart event, and --profile claude installs it.
  • Exit codes. exec exits 125 when Airlock could not run the tool, 126 when the tool's binary cannot be used, 127 when no such tool is declared, and otherwise with the tool's status.
  • Scope. Agent-written code that the user later runs outside the sandbox (hooks, .envrc, build scripts) is a documented non-goal.
  • CLI. New: airlock trust, airlock config, airlock session start/renew/list/reload/revoke, airlock daemon install/uninstall, airlock agent check/hook. airlock list becomes airlock tools list and asks the daemon. --no-config becomes --no-project-config; run --no-daemon becomes run --no-session. run, trust, status, session and daemon refuse inside the sandbox. Shell completion is planned for v2.1.

Direction and guidance

The topology doc records a central daemon as the longer direction: daemon-side shared secrets, tools that run centrally or locally, and the local daemon as the agent's single entry point. It compares one daemon per session, this design as written, a per-user daemon with a daemon-side layer, and the central server against that direction, and proposes five changes that would keep v2 on the path. They are not folded in.

The technical guidance doc lists the protocol and data model shapes to use while implementing this design (two message families, a principal per request, one protocol version, wire config that fails closed, error kinds as an enum, compiled session policy, a frame limit for admin requests, session ids on log entries) and postpones to v2.1 or later the additions the central daemon will need but v2 would not use.

The Decisions section of the design doc lists each choice with the alternatives it rejected. The Open questions section records how each review finding was resolved. There is also a comparison with mise trust.

🤖 Generated with Claude Code

paveq added a commit that referenced this pull request Sep 23, 2026
CI fails now and then with "socket ... has insecure permissions 0o755".
It hit run_embedded_exits_on_cancel on #14 and
synchronous_startup_explicit_path_uses_parent_as_sandbox_root on #13.

The socket gets its mode from the umask at bind time, and the umask is
process-wide. synchronous_startup and four socket tests each did
umask(077) / bind / umask(old) with no lock, and cargo test runs them on
parallel threads. If one thread restores 022 between another thread's
swap and its bind, that socket comes out 0755. The post-bind check then
rightly refuses it.

The swap now lives in bind_owner_only, behind a static mutex, and
synchronous_startup and the tests both go through it. The lock is in
production code, not a test-only mutex, so the integration test
binaries that call synchronous_startup in parallel are covered too. The
real daemon binds before any other thread exists, so it never waits on
the lock.

Stress run of `daemon::` lib tests, 200 iterations at 16 threads: 5
failures before, 0 after.
@paveq
paveq force-pushed the docs/runtime-and-config-design branch from 40b391b to 3504f2c Compare September 23, 2026 12:32
paveq added 2 commits October 6, 2026 14:10
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`.
A review of the proposal against the code found gaps that would keep it
from delivering its stated goals, or from working at all: project files
give the agent deferred code execution outside the sandbox, approval does
not cover the programs the config runs, the repo layer can reach personal
secrets, any daemon is reachable from any sandbox, and the socket location
is not stable (Linux agent env lacks XDG_RUNTIME_DIR, macOS $TMPDIR varies
per shell).

Record them as blocking items with a proposed direction each, list the
non-blocking follow-ups, and capture one-daemon-per-user with session
tokens as an open question. The Seatbelt rule-order check moves out of
"to verify": sandbox-exec confirms the last matching rule wins, so the
deny must be emitted after every allow.
@paveq
paveq force-pushed the docs/runtime-and-config-design branch from 8ff4d0e to 8d9cae0 Compare October 6, 2026 11:10
paveq added 2 commits October 6, 2026 15:54
Work through the review findings in the runtime/config design so it can
move to implementation.

- Narrow the anchor goal: code the user runs outside the sandbox already
  defeats secret isolation, so it is a documented non-goal (B1).
- Filter PATH and refuse binaries that resolve into agent-writable paths,
  since approving config bytes does not approve the programs they name
  (B2).
- Repo labels reach personal secrets only through an approved local
  `from = "global"` opt-in (B3, F4).
- Authenticate clients with sessions registered via an admin credential
  no sandbox can read; uid alone cannot tell the agent from the user
  (B4, B5).
- Take the runtime base from confstr / /run/user, not the environment
  (B6); emit Seatbelt denies last (B7); key trust copies by file name
  (B8).
- One multi-tenant daemon per user (Q1): one listener per host keeps a
  network transport open, and approved config applies to new sessions
  without a restart. Isolation rules are written as design constraints;
  moving the proxy out of process is tracked as F10.
Sessions and the single per-user daemon change the client protocol, the
daemon lifecycle and the trust boundary, not only where runtime files
live. The old name undersold the scope.
@paveq paveq changed the title docs: design the runtime dir move, config layering and trust docs: design Airlock v2 (per-user daemon, sessions, layered config, trust) Oct 6, 2026
paveq added 6 commits October 6, 2026 16:09
The v2 design decides the mechanism but not what users and agents see.
This companion doc covers the surface: user journeys as terminal
transcripts, help text for every command, the error catalog, and drafts
of the README, SKILL.md and examples changes.

It proposes changes U1-U14 to the design, among them trust prompts in
`airlock run`, folding `session exec` into `run --no-sandbox`,
`session reload`, distinct exit codes, and `airlock check` /
`airlock hook claude-code`. The hook keeps the agent from depending on
the skill alone to learn which tools go through Airlock.
`airlock completions <bash|zsh>` emits a dynamic completion script, so
`airlock exec -- <TAB>` can offer the session's tools and
`session revoke <TAB>` the running sessions. Only the daemon knows
either, and a script that calls back into the binary does not go stale
on upgrade.

The PreToolUse hook that redirected direct tool calls is left out. The
SessionStart context should be enough, and a hook on every shell command
costs a daemon round trip and needs a command parser that is never
complete. The completion proposal takes its number, U14.
Agent-facing commands now sit apart from the user's. `list` becomes
`tools list`, which can also show a running session's tools from the
user's terminal with `--session`. `check` and `hook` move under
`airlock agent`, and `status` stays the user's view (U5).

`daemon run` becomes `daemon start --foreground`, so "run" always means
starting an agent, and `logs` moves to `daemon logs` (U15). Inside the
sandbox, help hides the commands that need the user's terminal and says
which ones it hid, so a user in a sandboxed shell is not left guessing
(U16).

airlock-v2-questions.md lists 30 questions, both how-to and why, that
the v2 docs should answer.
The UX doc proposed U1-U16 against the design, so the two docs gave
different answers to the same questions. The design now describes all of
them, and the UX table stays as the record of what changed.

Answering the developer questions against the docs surfaced gaps, each
decided here: a lease on the Register connection so a session cannot
outlive its launcher, tokens bound to the agent's process tree, global
tools yielding to project tools, removing `run --no-sandbox` in favour of
`session start`, `{tool_state}`, `session renew`, `trust --expect-sha256`,
and exit 125 when approval is declined. The questions doc records each
decision and where it lives.
The design chose one daemon per user by comparing it with one daemon per
project and a relay router, all on local criteria. The intended direction
is a central Airlock daemon with daemon-side shared secrets and tools that
run centrally or locally, and none of the local options had been judged
against that. docs/airlock-v2-topology.md records the direction, what it
requires, compares four topologies against it, and proposes the changes
that keep v2 on the path. Its proposals are not folded in yet.

Decisions from the review, applied to the design, UX and questions docs:

- Dynamic shell completion moves to a "Planned for v2.1" section. It
  pins clap_complete's unstable dynamic feature, sends a daemon request
  on every TAB and needs hand-written shell delegation, and nothing in
  the daemon, the sessions or the trust model depends on it.
- `agent check` gets the paths it probes from the daemon. Computing them
  in the sandbox from XDG variables that the agent's environment may not
  carry would test the wrong paths and pass.
- The per-session output rate limit is dropped; throttling a tool's
  stdout is undefined and solves nothing the design states.
- The UX doc loses its verbatim help text and its README and SKILL.md
  drafts, which would drift during implementation. An Options table
  keeps the flags and decisions that only existed in the help text.
The daemon starts on demand and outlives the binary that started it, so
the launcher-to-daemon link is a compatibility boundary from the first
release. The topology doc also records a central daemon as the direction
after v2. Both argue for choosing a few shapes deliberately while writing
v2's protocol and session model, but v2 should not implement anything it
does not use.

docs/airlock-v2-technical-guidance.md separates the two. Shapes to use
now are things v2 needs anyway or that cost nothing beyond how a type is
written: two message families, a principal resolved by the listener, one
protocol version that covers the Register payload, wire config that
fails closed, error kinds as an enum, compiled session policy, a frame
limit for admin requests, and a session id on log entries. Additions the
central daemon will need but v2 would not use are postponed to v2.1 or
later, each with a note on why adding it later is safe.

The design doc's intro and the topology doc's constraints list point to
it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant