Skip to content

feat: implement Airlock v2 (sessions, layered config, trust) - #18

Open
paveq wants to merge 110 commits into
docs/runtime-and-config-designfrom
feat/airlock-v2
Open

paveq wants to merge 110 commits into
docs/runtime-and-config-designfrom
feat/airlock-v2

Conversation

@paveq

@paveq paveq commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Implements the Airlock v2 design from #14 and is stacked on it. The design, UX and technical-guidance docs there are the spec. Where this PR made a choice the docs left open, it is recorded in the design doc's new Implementation notes section.

What changes

  • One daemon per user, with sessions (src/daemon.rs, src/session.rs, src/protocol.rs).

    • The daemon starts on demand, or as a launchd/systemd service (airlock daemon install).
    • It reads no config and no process environment after startup. Everything a session needs arrives with Register.
    • Connections open with a protocol-version handshake. Requests come in two families, admin and session, each gated on its own credential (admin.token or a session token), with an ErrorKind per failure mapped to exit codes 125/126/127.
    • Sessions end when the airlock run lease closes, when their TTL runs out (session start, 12h), on revoke, or when the daemon stops.
    • Each token is bound to its anchor process's tree, checked by peer PID plus start time.
    • Every response passes the session's redactor and then a daemon-wide last-pass redactor; error messages included.
    • Each session may run at most 16 execs at once.
    • An automatic daemon exits 5 minutes after its last session ends.
    • A startup lock serializes concurrent auto-starts.
  • Runtime dir outside the project (src/runtime_dir.rs): confstr per-user temp dir on macOS, /run/user/<uid> or /tmp/airlock-<uid> on Linux. It is never taken from the environment and is validated with lstat. Every Seatbelt profile ends with denies for writes to it and for reading admin.token, plus F9's .git/hooks write deny.

  • Layered config (src/layers.rs, src/config.rs):

    • Layers, lowest to highest: global < repo airlock.toml < local airlock.local.toml.
    • Tool collisions follow the design (global yields; local needs override = true).
    • Repo items use only repo labels. The local file binds them, and from = "global" reuses a global binding.
    • {tool_state} keeps tool state outside the project.
    • deny_unknown_fields applies at every level, so the wire config fails closed.
  • Trust (src/trust.rs):

    • The trust store keeps byte-exact approved copies, written by temp file and rename.
    • Reviews show unified diffs with ANSI, bidi and zero-width characters escaped, and from = "global" lines are annotated with the global source.
    • Launchers prompt on a terminal and refuse elsewhere. airlock trust takes --yes and --expect-sha256.
  • Anchors and B2 (src/anchors.rs, src/exec.rs, src/secrets.rs):

    • XDG-derived anchors are checked for ownership and mode, refused inside the project root, and checked against every write grant from every layer (approved or not) plus --allow-write.
    • The filtered PATH is used both to resolve tools and to resolve secret commands' argv[0].
    • Binaries are refused if they resolve into the project or a write grant.
  • Launcher and CLI (src/launcher.rs, src/run.rs, src/main.rs, src/admin.rs, src/client.rs, src/inspect.rs, src/service.rs, src/agent.rs):

    • The full command surface from the UX doc: run, init [--local|--global], trust, config [--paths], status, exec, tools list [--session], session start|renew|list|reload|revoke, daemon start|stop|restart|logs|install|uninstall, agent check, agent hook claude-code|text.
    • --help is grouped by audience; inside the sandbox it shows only the commands that work there and names the hidden ones.
    • User-terminal commands refuse inside the sandbox.
  • Tool access levels (src/sandbox.rs, src/policy.rs). Each tool's built-in filesystem baseline is set by access, per tool or once at the top level:

    • none: the tool's binary plus what any program needs to start (dyld and the shared cache on macOS; the loader and libc on Linux);
    • system: the previous baseline;
    • default (the default): system plus read-only Nix store, Homebrew, /usr/local, MacPorts and Linuxbrew, so dynamically linked Nix and Homebrew tools work without extra_read.

    On Linux a tool now gets its own binary at every level; before, a tool installed outside the baseline directories could not execute.

  • Kits for the agent (src/kits.rs). Under airlock run, [agent] kits = [...] or --kit adds what a kind of work needs, separately from the harness --profile. Built in: rust, node, python, go, elixir; you can define your own in the global or local config.

    • isolated (the default) points the language's cache variables (CARGO_HOME, npm_config_cache, GOPATH, MIX_HOME, …) at a per-project directory under ~/.cache/airlock/kits/.
    • shared writes the real caches and reads the user's toolchain config and registry credentials, so private registries and publishing work.
    • Neither mode ever writes binaries or config (~/.cargo/bin, ~/.cargo/config.toml, ~/.npmrc, ~/.mix).
    • [kits.*] is accepted only in the global or local layer, so a repo can't switch you to shared mode. SECURITY.md covers shared mode's cache-poisoning risk.
  • Session isolation is enforced by a lint. clippy.toml disallows std::env::var*, set_var, remove_var and current_dir. Launcher-side functions carry narrowly scoped allows; no daemon-side request path reads the environment.

  • Docs and examples: README, SKILL, ARCHITECTURE, SECURITY, CLAUDE.md and TODO are rewritten for v2. The examples carry v2 headers, examples/team/ holds a three-layer walkthrough, and examples/secret-sources.toml replaces the startup scripts. All examples are parse-tested.

  • Demo: examples/demo/demo.sh walks through the whole flow in throwaway XDG dirs and does not touch your real config or trust store.

Try it

cargo build
examples/demo/demo.sh

Run it from a normal terminal, not inside airlock run. It shows:

  1. The unbound-label error, then init --local.
  2. The merged config with each item's layer.
  3. trust and session start.
  4. A redacted exec, and exit codes 127/125/3.
  5. A config edit by the agent, then trust and session reload.
  6. agent check and the SessionStart hook output.
  7. airlock run with a sandboxed agent.
  8. Revoke, then daemon stop.

Testing

  • macOS, outside Airlock: cargo test passes all 945 tests, none skipped, including every test that applies a real Seatbelt sandbox. examples/demo/demo.sh runs end to end with exit 0.
  • That first unsandboxed macOS run found three bugs that the skipped tests and Linux CI had missed. They are fixed here, each with a regression test:
    • exec with stdin at EOF (/dev/null, an empty pipe) failed: the client half-closed the socket, which the daemon reads as a disconnect.
    • Every airlock run session ended as soon as it was registered: the daemon half-closed the lease after Registered.
    • The launcher's lease kept the 10-second admin request timeout, which would have ended every airlock run session 10 seconds in.
  • Kits were checked with real cargo fetch/build runs under airlock run:
    • isolated mode leaves the real ~/.cargo untouched;
    • shared mode writes the registry cache and applies the user's config.toml;
    • shared mode can't write config.toml, credentials.toml or ~/.cargo/bin.
  • The test split is a build-time cfg. build.rs sets no_nested_sandbox when AIRLOCK_SANDBOX=1 or AIRLOCK_NO_NESTED_SANDBOX=1 (the Nix package build sets the latter). A plain cargo test anywhere else runs everything.
  • Linux: the real-sandbox suites passed in CI under Landlock up to 947f148. CI runs only for PRs into main, so this PR's later commits were checked with the cross-checked clippy below, not run on Linux.
  • cargo clippy --all-targets -D warnings is clean on macOS and on Linux (cross-checked).

Beyond the design

  • Linux sandboxes can now write to /dev/null. The Landlock baseline used to grant it read-only, which broke 2>/dev/null in tools and agents.
  • The home-root guard compares canonical paths. A home reached through a symlink no longer lets a project at $HOME through without allow_home_root.
  • Pre-trust error output and airlock config escape config text.
  • Tool names are restricted to [A-Za-z0-9._+-].
  • Tools run with their own name as argv[0], not the canonical binary path, so multicall binaries (Nix coreutils, busybox) work.
  • A Linux tool gets read and execute access to its own binary at every access level.

Known limitations

  • eval "$(airlock session start)" must run directly in the shell that will use the session (verified in bash and zsh). Piping its output through another command anchors the session to a short-lived subshell, which makes it unusable. This is documented.
  • Follow-ups F1–F3, F6, F7, F10, F11 and shell completion (V1) remain, as the design says.

🤖 Generated with Claude Code

paveq added 30 commits October 6, 2026 23:54
Airlock v2 never loads a changed airlock.toml or airlock.local.toml
without the user reviewing it first, since both can grant filesystem
access and name the commands that produce secrets, and an agent can
edit either one. This adds the pieces that review needs:

- TrustStore keeps a content-addressed copy of each approved file
  under $XDG_STATE_HOME/airlock/trust/<project_id>/<file_name>, so a
  later load can diff against exactly what was approved rather than
  just a hash. Writes go through a temp file and rename in the same
  directory, and every store path is checked with symlink_metadata
  before it's read or written.
- escape_for_terminal and render_review turn a file's bytes into
  text that is safe to print: TOML accepts control characters, ANSI
  escapes, bidi overrides and zero-width characters in strings and
  comments, and without escaping a diff could read differently from
  what it does.
- decide() is the pure --yes / --expect-sha256 logic for scripted
  approval (airlock trust on a non-terminal, or in CI), kept separate
  from prompt_yes_no so it has no I/O to mock in tests.

See docs/airlock-v2-design.md's "Trust" section and
docs/airlock-v2-ux.md's trust-related journeys and Messages table for
the behavior this follows.

This deviates from the coordinator's trust.rs sketch in
.git/v2-plan.md, which takes anchors.rs's Anchors and layers.rs's
LayerFile. Neither module exists in this worktree yet (owned by
parallel phase-1 agents), so TrustStore::open takes a plain &Path
and state()/approve() take root: &Path and file_name: &str plus the
bytes; the coordinator adapts call sites when it wires the modules
together.
v2 moves the daemon's socket/PID/CA files out of the project root and into
a per-user runtime directory, and needs to protect the trust store, global
config and that runtime base from redirection by a committed mise.toml or
.envrc (see docs/airlock-v2-design.md, "Runtime directory" and "Protecting
the anchors"). Token binding (same doc, "Token binding") additionally needs
a way to walk the peer's process tree so a stolen token is useless outside
the shell or launcher that received it.

These three modules lay the groundwork other v2 work builds on:

- runtime_dir.rs locates the per-user base (confstr on macOS, /run/user/<uid>
  with a /tmp fallback on Linux), never reading TMPDIR or XDG_RUNTIME_DIR,
  and validates it with the lstat checks the design calls for. It also
  checks the socket path against sun_path's platform limit, since the base
  is now computed rather than fixed.
- anchors.rs resolves the trust store, global config and runtime base from
  XDG variables passed in as a closure (never read directly, so daemon-side
  code can't depend on this module to read the process environment) and
  validates them against the project root and sandbox write grants, with
  the macOS $TMPDIR ancestor exception the design calls for. overlaps() and
  is_inside() canonicalize the longest existing prefix so a grant on a
  not-yet-created path still compares correctly.
- process_tree.rs gets a process's start time and parent pid (proc_pidinfo
  on macOS, /proc/<pid>/stat on Linux, careful to split on the last ')' so
  a comm field with spaces or parens doesn't shift the other fields) and
  walks ancestry for token binding.

Deviation from the contract: anchors::validate()'s own grant-overlap check
reports AnchorError::Covered with a generic "sandbox configuration" file
label, since it has no config file context of its own. The precise
"<file>: filesystem.write ..." message comes from
check_grants_against_anchors(), which config.rs (P1-B) calls with the real
file labels; validate()'s check is a backstop for anchors constructed
without going through config at all.
One daemon per user now needs a protocol that can tell admin requests
(Register, Reload, session list/revoke/renew, Logs, Stop) from session
requests (Exec, List, Check) and gate each independently, carry a
session id and bearer token that can be logged and compared without
risking the secret, and fail closed on a field it doesn't recognize
instead of silently ignoring a policy the launcher expected enforced.
See docs/airlock-v2-technical-guidance.md and the "Sessions" section of
docs/airlock-v2-design.md.

Session tokens (`airlock_<id>_<43 base64url chars>`) and the admin
token zeroize on drop, compare in constant time via `subtle`, and their
Debug impls never print the secret part (SessionToken shows only its
id). WireSecret, the resolved secret values Register/Reload carry,
gets the same Debug treatment even though the value itself has to
reach the daemon in the clear on the wire.

RegisterPayload.config is a serde_json::Value placeholder
(`WireConfig`) rather than config::RawConfig: another phase-1 agent is
making that type public and Serialize/Deserialize in a parallel
worktree, and depending on it here would couple this module to work
in flight on another branch. The coordinator swaps the alias in at
merge.

The v1 ClientMessage/DaemonMessage/LogEntry types move into
protocol::v1, unchanged, so daemon.rs and client.rs keep compiling
until phase 2 rewrites them against the types above; only their import
paths changed, here and in the tests/*.rs integration tests that drive
the real daemon binary over the v1 wire format. protocol::v1 is marked
for deletion once that lands.
…for v2

Approving airlock.toml approves the config, not the code it runs (B2 in
the v2 design doc). Today resolve_binary and a secret's `command = [...]`
both walk the daemon's own PATH and run with its own environment, so a
sandboxed tool can plant a binary ahead of a safe PATH entry, or rewrite
a script a secret command invokes, and the daemon will trust it on the
next request or refresh.

filter_path(path_var, root, write_grants) in exec.rs drops the PATH
entries a sandboxed tool could have planted into — relative entries,
entries inside the root, entries inside or equal to a write grant — and
resolve_binary_in canonicalizes whatever it finds and refuses it if that
lands inside the root or a write grant anyway, which catches a symlink on
an otherwise-safe entry. secrets::collect_secrets_with and
run_command_with_ctx apply the identical check to a `source = "command"`
secret's argv[0], including one that contains a `/` (so
`command = ["./scripts/token.sh"]` is refused the same way a planted `gh`
would be), and run under an explicit CommandContext (environment
snapshot, filtered PATH, cwd, root, write grants) instead of the daemon's
own environment — never falling back to it for `source = "env"` either.

refresh.rs gets a parallel refresh_once_ctx so a session's background
refresh can use its own CommandContext once sessions exist; it shares the
slot/redactor update logic with the existing refresh_once via
apply_refresh_result.

resolve_binary and build_env (the v1, process-env-reading functions) stay
for the current single-session daemon. build_env is now a thin wrapper
around build_env_from. resolve_binary could not be rebuilt on
filter_path/resolve_binary_in without changing its behavior — it
tolerates relative PATH entries (resolved against the process cwd,
shell-style) that filter_path deliberately drops — so it keeps its
original PATH-walking helper, renamed to walk_path_var to free the
resolve_binary_in name for the new signature.
…t-hooks deny

Airlock v2 moves the daemon's socket, PID file, admin token, and session CA
certs into a runtime directory that is separate from the project root. On
macOS that directory normally sits inside the same `$TMPDIR` every profile
already gets read-write access to, so without an explicit carve-out the
existing `$TMPDIR` grant would let any tool or the agent overwrite the
socket or read `admin.token` straight out from under the daemon.

`ToolPolicy` and `AgentPolicy` (src/sandbox.rs) gain `runtime_base`,
`tmpdir`, and `git_hooks_deny`, all `Option<PathBuf>`. The macOS builder
stops reading `std::env::var("TMPDIR")` directly — `generate_agent_profile`
now takes it from `AgentPolicy::tmpdir`, which `run.rs`'s two
`run_with_embedded_daemon`/`run_with_config_and_secrets` call sites read
from the environment instead, so sandbox.rs itself touches no process
state. `ToolPolicy::tmpdir` is added for parity but stays unread by
`generate_profile`: a tool only ever gets `$TMPDIR` scratch access if its
own config grants it via `extra_write`, so there's nothing to source
automatically.

Both macOS builders now end, unconditionally, with `emit_final_denies`:
F9's `(deny file-write* (subpath ".git/hooks"))`, then the runtime-base
write deny, then the `admin.token` read deny — in that order, after every
allow, including profile-specific rules (claude/claude-relaxed), `extra_write`,
and `--allow-write`. Folding this into one function called exactly once at
the end of `generate_profile` and `generate_agent_profile` makes "last" a
property of the code's structure rather than something that has to be
re-verified every time a new allow rule is added earlier in either builder.
`policy::build_tool_policy`/`build_agent_policy` set `git_hooks_deny` to
`<sandbox_root>/.git/hooks` unconditionally (harmless on a non-git root);
`runtime_base` stays `None` until the daemon actually has one to give
(phase 2).

The agent also needs to keep reaching the daemon once its own write grant
on the base is denied. Seatbelt's existing mDNSResponder rule already shows
that a Unix-socket connect needs `file-read*` on the socket path itself,
not just `network-outbound` — so `emit_runtime_base_socket_access` grants
exactly that, plus an explicit `(remote unix-socket (path-literal ...))`
rule, unconditionally rather than gated on `requires_network` (connecting
to the daemon isn't part of the agent's general network policy and
shouldn't be able to be switched off by it). Proxy tools don't need a
parallel mechanism: their session CA cert is just another entry in
`read_paths`, and `emit_filesystem_rules`'s `subpath` rule already matches
a leaf file exactly — read access it grants is untouched by denies that
are scoped to writes and to the single `admin.token` literal.

Linux's Landlock is allow-only and already has no path that grants
`/run/user/<uid>` or `/tmp` by default (verified in
`landlock_baseline_never_grants_tmp_or_run_user`), so there's no carve-out
to add there; `runtime_base` is simply never read by
`build_landlock_profile`, confirmed by tests that set it and show the
build is unaffected.

Tests cover: the three deny rules landing last, in order, under a
`$TMPDIR` grant, a grant equal to the base, a grant that's an ancestor of
the base, and both claude/claude-relaxed profile kinds; F9 and the
runtime-base denies present/absent; the CA single-file read grant
surviving the final denies; the socket access grant firing regardless of
`requires_network`; and the `tmpdir` field's canonical-only behavior
matching the old env-var code exactly. Two tests that apply a sandbox for
real (one per platform) are `#[ignore]`d per the project's sandbox-testing
split, since this session can't nest Seatbelt/Landlock — they prove an
agent profile can't write into the base or read `admin.token` there, but
can still connect to a listening socket at `<base>/airlock.sock`.

`tests/exec_integration.rs` needed the same three new `ToolPolicy` fields
added to its existing literals to keep compiling; no behavior there changes.
Airlock v2 moves from one project-owned airlock.toml to three layers: a
personal global config outside any project, the team's repo file, and a
local file for per-user bindings and overrides. This lets a project ship a
shared tool definition while each teammate supplies their own credential,
and lets a user carry personal defaults (a 1Password binding, a favorite
tool) across every project without committing them anywhere.

The raw TOML types move from a tagged `source` enum to a flat, fully
optional RawSecretSpec (config.rs#L585-640) because legality now depends on
which layer a `[secrets.*]` entry sits in, not just on `source` — the repo
layer may omit it entirely (a label the project needs but leaves to the
user), and the local layer may write `from = "global"` to borrow the user's
own binding. A tagged enum could not express that without either rejecting
legal repo/local shapes or accepting illegal ones. `deny_unknown_fields` now
also applies at the top level (config.rs#L324-361 in the old layout),
dropping the `_extra` catch-all v1 used for forward compatibility — v2 wants
a typo in a config file to fail loudly, in every layer alike, since this
same RawConfig is what crosses the wire to the daemon once the launcher
lands (decision #3 in the v2 implementation contract).

Discovery, merging, and the cross-layer rules (duplicate tools, label
scoping, `{tool_state}` resolution) live in the new src/layers.rs rather
than growing config.rs further — config.rs now owns per-file parsing and
the two ends of the pipe (resolve_wire_config for the daemon,
local_stub/global_config_template for `airlock init`), while layers.rs owns
everything that needs to see more than one file at once.

Kept for now, removed in phase 2: `load_config`/`load_config_from_file`
(the pre-layering single-file loaders the v1 daemon still calls) and
`Config`'s socket_path/pid_path/ca_path fields (still read by daemon.rs,
client.rs, and main.rs).

Deviations from the v1 implementation contract's config.rs/layers.rs
section, noted for the agent assembling phase 1:
- `load_layers` takes `home: &Path` and `global_config_path: &Path`
  directly rather than an `anchors: &Anchors` parameter — anchors.rs
  (P1-A) doesn't exist in this worktree yet. The two paths are exactly
  what load_layers needs out of that type, so wiring it through an
  `Anchors` value later is a mechanical change at the call site.
- `project_id` lives in config.rs (the contract allowed either); layers.rs
  re-exports it since trust.rs is expected to reach it through there.
- `MergedConfig` does not yet carry everything `airlock config` will want
  to render (the UX doc's full layered listing) — it has enough provenance
  (ToolProvenance, SecretProvenance) for the merge-rule tests and for
  `inspect.rs` to extend in phase 3.
…v2 modules

The phase-1 modules were written in parallel and each grew its own copy of
project_id/sha256_hex, LayerKind and the inside-a-directory check. One copy
of each keeps the trust store, the layer records on the wire and the
binary location check agreeing on the same answers. The Register payload
now carries the TOML raw types instead of a JSON placeholder, so a daemon
rejects config fields it does not know.
macOS refuses to apply a Seatbelt profile inside another, so a test that
sandboxes a child fails with EPERM when the suite itself runs under
Airlock, as an agent's does. build.rs sets the no_nested_sandbox cfg
there (AIRLOCK_SANDBOX=1) and in the Nix build sandbox
(AIRLOCK_NO_NESTED_SANDBOX=1), and those tests carry
cfg_attr(no_nested_sandbox, ignore). Everywhere else, CI included, a
plain cargo test runs them.
The v2 lifecycle keeps the automatic daemon (started on demand, idle-exits
after sessions end) and adds a service mode: a launchd agent on macOS or a
systemd user unit on Linux that keeps the daemon up with no sessions
(docs/airlock-v2-design.md "Lifecycle"). This registers that service.

Both platforms share the same shape: generate the definition file, write it
only when its content differs from what's on disk (install is otherwise a
no-op — "already installed"), then tell the service manager to (re)load it.
A changed file needs an unload before the reload, since launchd/systemd
both refuse to bootstrap/enable a label that's already loaded under the old
definition.

launchctl/systemctl calls go through a CommandRunner trait
(src/service.rs#L36) so install()/uninstall() run hermetically under test —
no test ever touches the user's real LaunchAgents or systemd state. Only
install_cmd()/uninstall_cmd() read the process environment (HOME, the
current exe, euid, XDG_CONFIG_HOME), per the coming
clippy::disallowed_methods rule for env access outside startup/launcher
code.

Per the coordinator's resolution of the open question in
docs/airlock-v2-ux.md ("daemon install while an automatic daemon runs"),
install never stops a daemon it finds already running; it just notes that
`airlock daemon restart` will swap it for the service.
Adds src/inspect.rs: pure renderers for `airlock config` and `airlock
status` plus thin *_cmd wrappers that main.rs will wire up once the
daemon/launcher/CLI agents land their pieces (daemon.rs, run.rs,
client.rs, admin.rs). Keeping rendering pure and taking every
environment-derived input (cwd, home, anchor paths) as an explicit
parameter is what makes config_cmd/status_cmd/init_cmd testable without
a real daemon, a real git, or a real HOME — see the unit tests in
inspect.rs, each modeled on a transcript in docs/airlock-v2-ux.md.

layers::MergedConfig's provenance tracked which layer a tool or secret
came from but not which layer a *setting* (timeout, filesystem.read/
write, agent.passthrough_env, agent.env.<key>) came from, which
`airlock config`'s settings section needs to annotate each one and to
show agent.env's "local (overrides repo)" note. Extended
layers::Provenance with a SettingsProvenance that layers::merge now
fills in alongside the existing tool/secret provenance, reusing the
same per-layer loops rather than re-deriving it from the wire config.
Sessions, config layering and trust replace the v1 single-tenant daemon
and single airlock.toml, so every user-facing doc needs to describe a
different mental model: one daemon per user, a launcher that approves
config and resolves secrets before registering a session, and a session
token (not the socket alone) as the authorization boundary.

- SKILL.md: tools list/exec/agent check need a session; the 125/126/127
  exit codes and what to tell the user for each; never run airlock trust
  or session reload from inside the sandbox.
- README.md: quick start without daemon start or in-project runtime
  files; supplying secrets through the launcher; team vs. personal
  config (airlock.local.toml, the global file, from = "global",
  init --local); approving config; sessions for IDEs and harnesses with
  their own sandbox; harness hooks; an always-on daemon; {tool_state}.
- ARCHITECTURE.md: the module map, startup sequence, config layering and
  Register/Reload, sessions (lease, TTL, token binding), the anchors,
  and the wire protocol's two families, kept in sync with what the
  sandbox/redaction/proxy sections already described.
- SECURITY.md: the session token as the real trust boundary, token
  binding, protecting the anchors, a new External sandboxes section
  (#external-sandboxes) with a Claude Code sandbox config, F9 as
  defense in depth, and a "does not protect against" entry for
  agent-written code run outside any sandbox.
- CLAUDE.md: the trust-boundary and session-isolation invariants, and
  the actual sandbox-test split (#[cfg_attr(no_nested_sandbox, ...)]).
- TODO.md: v2 follow-ups (F1-F3, F6, F7, F10, F11, V1) as pointers to
  the design doc; removed the peer-uid-check item v2's session
  principal resolution now covers.
- docs/airlock-v2-design.md, docs/airlock-v2-ux.md: mark the design and
  UX surface as implemented, and F9 as implemented rather than deferred.
Headers across the single-file examples pointed at `airlock daemon
start`, which no longer exists as a setup step — the daemon starts on
demand from `airlock run`. Updated every header to the copy-and-run
flow, and switched tool config directories (GH_CONFIG_DIR,
CLOUDSDK_CONFIG) to {tool_state} now that it exists, keeping them out
of the project and the agent's reach. KUBECONFIG stays under
{sandbox_root} where gcloud and kubectl need to share one file -
{tool_state} is exclusive to the tool that declares it, so it can't
serve a file one tool writes and another reads.

Removed 1password-startup.sh and vault-startup.sh (they wrapped
`daemon start`, which they can no longer do) and replaced them with
secret-sources.toml, a reference for `source = "command"` against
1Password, Vault, `gh auth token` and gcloud, plus the `op run --
airlock run` pattern for `source = "env"`.

Added examples/team/: a repo file with two secret labels left unbound
for each user, a local file binding one with `from = "global"` and one
directly, a global file, and a README with the merged result and the
first-run transcript from docs/airlock-v2-ux.md's "Joining a team
repo".

tests/examples_parse.rs loads every example through airlock::layers
as the layer kind it's meant to occupy (repo for the single-file
examples, the trio for team/) and asserts it merges, so a schema
change that breaks an example fails CI instead of being discovered by
a user pasting it in.
Phase 2 replaces the single-project daemon with sessions: a client binds
to one project's root, config and secrets via a token, and the daemon
serves any number of projects at once (docs/airlock-v2-design.md,
"Sessions"). This introduces the two types that make that possible:

- SessionPolicy holds everything Register/Reload compiles once per
  session — config, secret store, redactor, proxy, filtered PATH,
  refresh tasks — behind one Arc that Reload swaps in a single step, so
  an in-flight exec never sees a config change mid-flight.
- Sessions is the registry: live sessions keyed by id, plus a bounded
  history of why a recently-ended one is gone (revoked vs. expired vs.
  lease closed), so a request that arrives just after gets the right
  error. Session refs resolve by id, unique id prefix, or unique name.

build_session_policy is the one function that turns a RegisterPayload
into a SessionPolicy: it never touches the process environment or runs a
command itself (the launcher already resolved every secret), and it
takes an on_refresh callback so daemon.rs can rebuild its cross-session
global redactor without this module knowing other sessions exist.
The v1 refresh task re-ran a source = "command" secret against the
daemon's own environment and PATH. With sessions, a secret belongs to
one session, and its refresh must use that session's filtered PATH and
environment snapshot — never the daemon's own (docs/airlock-v2-design.md,
"Session isolation") — exactly as refresh_once_ctx already did, just
unused until now.

spawn_all_ctx replaces spawn_all: it takes a session's CommandContext
instead of reading the environment, and an on_refresh callback invoked
after every successful refresh so the daemon can rebuild its
cross-session global redactor. The old env-reading spawn_all/RefreshTask
path is deleted along with the v1 daemon that was its only caller.
One daemon now serves every project through sessions instead of one
embedded daemon per `airlock run`. This deletes the v1 request path
entirely — synchronous_startup(start_dir, config_path), run_embedded,
the project-dir runtime files it bound, and every v1 ClientMessage/
DaemonMessage handler — and replaces it with the v2 protocol's
handshake/auth/dispatch flow:

- Per-connection: peer-uid check, version handshake (mismatch closes with
  IncompatibleProtocol), one Request read under the family-appropriate
  frame cap, then dispatch gated on whether the auth kind matches the
  request family.
- Admin family: Register builds a session (anchor from the peer PID for
  a lease, from its parent for a TTL) and, for a lease, holds the
  connection open until EOF or any further line revokes it. Reload swaps
  a session's SessionPolicy in one step and reports what changed.
  ListSessions/Revoke/Renew/Tools/Logs/Stop round out the set; Revoke
  resolves every reference before ending any session, so a bad ref in
  the list ends nothing.
- Session family: List/Check/Exec read only the calling session's own
  Arc<SessionPolicy> — no global lookup by label anywhere on this path.
  Exec gained a per-session concurrency cap (Busy past 16), and its
  output now passes the session's own redactor and then the daemon-wide
  global redactor rebuilt from every live session's secrets, so a bug
  that put one session's secret in another's output still gets masked.
- Idle-exit (automatic daemon only) and the TTL sweep run on independent
  timers: idle-exit needs a short tick to be observable under a debug
  test override, while the TTL sweep can stay cheap and infrequent.

The double-fork/readiness-pipe mechanism, socket bind/verify, process
hardening and the exec I/O/redaction pipeline are carried over from the
v1 daemon essentially unchanged — what moved is what state they close
over (DaemonState and a session's policy instead of one global config).

Tests start the async server in-process against a temp RuntimeDir
(no fork) and drive it over a real socket, covering the handshake,
every auth failure mode, process-tree binding, register/reload/revoke/
renew/logs/stop, idle-exit, the global redactor, the exec cap, and the
pre-spawn exec error paths that need no sandbox.
e2e_helpers.rs no longer calls daemon internals directly — it drives the
real compiled airlock binary (airlock daemon start --foreground, under
AIRLOCK_TEST_RUNTIME_DIR) and registers a session over the real wire
protocol, built from a plain airlock.toml string. That stands in for the
not-yet-written launcher: it resolves `source = "env"`/`source =
"command"` secrets from this process the same way a launcher would,
just without the launcher's own error handling, since that's not what
these tests are about.

Every dependent test file (exec_e2e, stdin, timeout, redact_e2e,
proxy_e2e, concurrent, disconnect, shutdown, logs) moves from
protocol::v1's ClientMessage/DaemonMessage to v2's Request/SessionRequest/
StdinFrame, and from a bare socket path to the registered session's
token. Two tests needed more than a wire-shape change:

- The proxy CA assertions used a fixed per-project `airlock-ca.pem`
  path; v2's CA lives per session at `<runtime base>/ca/<id>.pem`
  (DaemonHandle now carries the ca_path from the Registered reply).
- A v1 test covering a stale per-project CA surviving a crashed
  daemon's startup sweep has no v2 equivalent: the CA is session state
  in daemon memory, not a project-directory file, so there is nothing
  for a crash to leave behind and nothing central to sweep.

exec_integration.rs (owned by this worktree, unchanged otherwise — it
drives exec::spawn() directly, which this phase doesn't touch) gained
the #[cfg_attr(no_nested_sandbox, ignore = ...)] markers it was missing;
every one of its tests applies a sandbox and cannot run nested inside
Airlock's own.

concurrent_integration.rs and logs_integration.rs also gained one new
test each (the exec cap, and log-filtering-by-session) to exercise
behavior this phase added that the v1 suite had no equivalent for.
The launcher, and every `session`/`daemon` management command, run
before any tokio runtime exists (main() stays synchronous per
CLAUDE.md), so they need a blocking way to speak the protocol's admin
family: connect, hello, send one AdminRequest, read one reply. Connection
unwraps a DaemonMessage::Error straight into AdminError::Daemon so call
sites handle the happy path without re-checking for it, and exposes
into_raw() for `airlock run`'s lease connection (held open for the
agent's lifetime; a watcher thread blocks on it for EOF).

`exec`/`tools list` keep using tokio in client.rs — they stream
stdout/stderr and have no reason to avoid a runtime.
prepare() is the synchronous pipeline shared by `run`, `session start`
and `session reload`: discover the project's layers, merge and
validate them before anything is shown (per the design doc's "trust
the merged config before you ask about any file"), check the three
anchors against every write grant including --allow-write, review and
(on a terminal) prompt for approval of each unapproved repo/local/
--config layer (U1), create {tool_state} dirs, filter PATH and resolve
secrets through the launcher's own environment and filtered PATH —
never the daemon's.

ensure_daemon() handles "Upgrading Airlock": reuse a compatible daemon,
restart an idle one that's running a different version, refuse outright
on an incompatible protocol with sessions still attached, or spawn a
fresh automatic one by re-exec'ing this binary (never an in-process
fork, so the daemon's own fork-then-daemonize stays before any tokio
runtime).

register()/reload() turn a Prepared into the wire RegisterPayload and
round-trip it through admin.rs. run_trust() is `airlock trust` itself:
same discovery and validation, but --yes/--expect-sha256 decide the
batch instead of a single always-prompt path, and it tells the user
when running sessions for the project are left on the previous config.
run_agent() now drives launcher::prepare() instead of embedding a
daemon: it registers a Lease session with the per-user daemon (unless
--no-session), builds the agent's sandbox policy from the launcher's
resolved Config plus runtime_base/tmpdir/git_hooks_deny, sets
AIRLOCK_ADDR/AIRLOCK_SESSION in the agent's environment, and holds the
Register connection open as the session's lease for as long as the
agent runs — a watcher thread blocks on it for EOF so a closed lease
(daemon stopped, session revoked) is reported to the user without
killing the agent.

build_agent_env() now takes the launcher's env_snapshot explicitly
instead of reading the live process environment, matching the design
doc's "no process-global state after startup" for the launcher's own
state, not just the daemon's.

--profile claude's default command carries the SessionStart hook
settings (CLAUDE_HOOK_SETTINGS_JSON) alongside the existing
sandbox-disable flag, so `airlock agent hook claude-code` (phase 3)
and the inner Seatbelt-nesting guard both apply out of the box.
exec() and tools_list() now read only AIRLOCK_ADDR/AIRLOCK_SESSION
(never discovering config or starting a daemon) and speak the v2
handshake: ClientHello, read Hello, send one session-family Request.
Every daemon Error.message is relayed as-is behind an `airlock: `
prefix and mapped to its ErrorKind's exit code — the daemon already
formats messages to match the UX doc's agent-facing table verbatim.

UnknownTool gets the one documented special case: a follow-up List on
a second connection, diffing the session's registered layer hashes
against the files on disk, so an agent that hit a stale "no tool
named" error can also relay the config-changed hint.

`tools list --session <ID>` goes through admin.rs instead — the one
case run from the user's terminal with admin.token, not a session
token.
Replaces the v1 clap tree with docs/airlock-v2-ux.md's "Command
surface": run, init, trust, exec, tools list, session
start/renew/list/reload/revoke, daemon start/stop/restart/logs, plus
clap definitions (but a plain `not_implemented()` dispatch, exit 125)
for the phase-3 commands — config, status, agent check, agent hook,
init --local/--global, daemon install/uninstall. Removed entirely:
list, logs (top-level), daemon run, --no-config, --no-daemon, global
--config, AIRLOCK_SANDBOX_ROOT.

sandbox_refusal() implements "Commands refused inside the sandbox":
checked once, before dispatch, against AIRLOCK_SANDBOX=1, with the
exact per-command message from the UX doc (status points to `agent
check` instead of the generic wording).

session list/reload/revoke resolve their session set the same way
everywhere (--here matches the project discovered from the current
directory, an id/prefix/name match for the rest), and session reload
replays each target's own recorded discovery mode (WireMode) rather
than the caller's current directory, per "Reloading a session" in the
design doc.
Rewrites the three suites P2-H owns against the new binary surface:
top-level help/version/init, the sandbox-refusal table, and the
agent-facing "no session" messages (cli_integration.rs); discovery,
merge-validation and trust-refusal paths for `airlock run`
(run_integration.rs); and the no-daemon-running CLI paths for `airlock
daemon` (daemon_integration.rs).

Tests that need a real v2 daemon are written against the intended
behavior but #[ignore = "needs the v2 daemon (phase 2 merge)"], since
this worktree only has P2-G's daemon::start shim; the coordinator
un-ignores them after the phase-2 merge. Tests that need a nestable
sandbox keep the existing #[ignore] convention — this session already
runs inside Airlock's own Seatbelt profile, which refuses to nest.

Does not touch the suites P2-G owns (exec/stdin/timeout/redact/proxy/
concurrent/disconnect/shutdown/logs *_integration.rs, e2e_helpers.rs);
those fail here the same way they would on a bare v1 checkout, for the
same nested-sandbox reason, and are out of scope for this change.
Matches the coordinator's updated marker convention from build.rs (cherry-
picked separately): a test that applies a sandbox should be ignored via
the no_nested_sandbox cfg, not a plain #[ignore], so CI and a developer's
terminal still run it with plain cargo test.
The daemon and launcher suites were written against each other's stubs.
With both halves in place, the tests that waited for the real daemon run
again. The fixtures give the runtime dir mode 0700, as the daemon
requires. The spawned binaries stand in for the user's terminal, so they
drop AIRLOCK_SANDBOX. The stop test reaps its foreground daemon, because
an unreaped zombie still answers the PID poll.
The daemon serves one request per connection. session revoke and session
reload list the sessions first and then sent the second request on the
same socket, which the daemon had already closed, so both failed with a
broken pipe. A request after the first now reconnects.

The session auth errors now use the UX wording, so an agent can relay
them to the user as they are.
The test only compiles on Linux, so the missing import showed up in CI
and not on macOS.
Only the launchd plist needs XML escaping, so on Linux the function was
dead code and -D warnings failed the build.
The home-root guard compared the canonical root with home as given, so a
home reached through a symlink let a project at $HOME through without
allow_home_root. The layer tests had only passed on macOS by the same
accident: their temp dir canonicalizes to /private/var, so it never
equalled the home they passed.

The sandbox probe scripts ran their write probe on the ':' builtin. A
failed redirection there ends a POSIX shell, so dash exited with status 2,
which the Landlock test read as 'admin.token was readable'. The probe now
runs in a subshell, and the Linux script writes its scratch output to the
granted dir, since the baseline grants /dev/null for reading only.
Copied straight into the temp dir, the example's project root was the
home the merge saw, and the home-root guard refused it once it compared
canonical paths.
The agent relays this message to the user, so it names the project,
not an internal root.
paveq added 26 commits October 7, 2026 10:56
build.log was a local Chromium crashpad log committed by accident in
947f148.

Several comments pointed readers at "the v1 rationale" or the
implementation contract, neither of which exists in the tree any more.
The real reasoning is restored inline where it still applies (redactor
snapshot timing, shutdown_background, build_agent_env layers).

policy.rs still described the socket as living at
{sandbox_root}/airlock.sock and the runtime directory as not yet wired
in; both stopped being true when the socket moved to the runtime
directory, which callers now grant through runtime_base. The test that
pinned the old layout only asserted that an unused path was absent, so
it goes too.
`session reload` re-ran `launcher::prepare` with empty
`extra_write_grants` and `cli_kits`, so the reloaded policy lost every
`--allow-write` dir and `--kit` grant while the agent's sandbox, fixed at
launch, could still write them. The PATH filter and `resolve_binary_in`
then accepted those dirs again, and an agent could plant e.g. `gh` in an
`--allow-write` dir on PATH and have the next `airlock exec gh` run it
with GH_TOKEN. The launcher's own secret-command resolution during the
reload had the same gap, unsandboxed.

The session now owns its write grants: `SessionInfo` carries them, reload
hands them back to `prepare`, and `build_session_policy` keeps every grant
a session has ever had and re-filters the payload's PATH against them, so
a launcher that forgets one still can't shrink the set. `--profile`
read-write paths become write grants too; they were agent-writable but
never reached the anchor check or the PATH filter, which SECURITY.md
already claimed they did.

SessionInfo's shape changed, so PROTOCOL_VERSION goes to 3.
…alues

The daemon-wide last-pass redactor ran `redact_bytes` on each chunk the
session pass emitted. Those chunk boundaries are arbitrary, so another
session's secret printed across two of them matched neither half and
reached the client in plaintext — the exact leak the backstop exists for.

Both passes now run as chained `StreamRedactor`s in one async task per
stream, flushed with `finish()` at EOF. That replaces the session pass's
`spawn_blocking` + `redact_stream` bridge and its hand-written
`ChannelReader`/`ChannelWriter`, so the daemon has one streaming
redaction mechanism instead of two and no blocking thread per exec.

The global redactor was also built from each session's current values
only, while a session's own redactor keeps each refreshed secret's
previous value too. Refresh now shares that map with the session policy
and drops its lock before `on_refresh`, so the global rebuild covers the
same generations.
The `~/.claude.json` and GlobalPreferences rules embed a HOME-derived path
in a raw `(regex #"...")` literal. `regex_escape` escaped ERE
metacharacters but not `"`, which that literal has no escape for, so a
HOME containing a quote ended the literal early and the rest of the path
was parsed as SBPL. The control-character check that `escape_path`
applies to every other path had also been copy-pasted into one regex
site and was missing from the other.

Both sites now go through `escape_regex_path`, which runs the shared
control-character check, refuses a quote, then regex-escapes.
`check_env_collision` read a built-in kit's env var names from
`builtin_isolated_env_names`, a list kept by hand next to the expansion
code that actually sets them. It had drifted: isolated elixir also sets
`MIX_ARCHIVES`, which the list lacked, so an `[agent.env.MIX_ARCHIVES]`
entry was silently overridden by `apply_kit_env` instead of rejected as a
collision.

The names now come from running the kit's expansion with placeholder
inputs. Which names a mode sets never depends on the inputs, only their
values do, so the two can't disagree again.
The rule for turning a session ref into one session (exact id, else a
unique id prefix or name, ambiguity an error) existed three times:
`Sessions::resolve_ref` daemon-side, `resolve_session_ref` client-side
with a "keep in sync" note, and `find_session` in main.rs, which had
already drifted to "first match wins". So `session renew` could print a
different session than the one the daemon actually renewed.

The rule now lives once, in `resolve_by_ref`, and all three go through
it. Renew's expiry text reuses `ends_text`/`now_unix` instead of a copy.
Every admin subcommand matched the raw `DaemonMessage` reply by hand,
twelve copies of "expect this variant, else print 'unexpected response
from the daemon' and exit 125", while `AdminError::UnexpectedResponse`
existed and was never constructed.

The copies had also hidden dead code: `read_message` already turns a
`DaemonMessage::Error` into `AdminError::Daemon`, so revoke's
`Ok(DaemonMessage::Error { kind, .. }) => kind.exit_code()` arm and
`launcher::reload`'s matching arm could never run, and revoke's daemon
errors took the generic 125 path instead. No admin error kind maps to
anything but 125 today, so the exit code didn't visibly change, but the
intent was lost.

`Connection::request_ok` and `list_sessions` now return
`UnexpectedResponse` for a reply of the wrong shape, and `admin_failed`
in main.rs is the one place that turns an `AdminError` into a message
and an exit code, using the daemon's `ErrorKind` when it sent one.
`airlock trust` reimplemented the front half of `launcher::prepare`
(discover, merge, resolve, check write grants against the anchors) and
had drifted from it: it skipped kit validation, the `[agent.env]`-vs-kit
collision check and the kits' write grants. So it could approve a config
that `airlock run` then refused, e.g. one setting `[agent.env.CARGO_HOME]`
alongside the isolated rust kit, or a kit whose write dirs reach an
anchor.

Both now call `discover`, which owns that sequence. Trust passes no
`--allow-write` or `--kit`, since those are launch flags rather than
config the trust store approves.
… alike

The per-entry env checks (valid name, no proxy-reserved var, no secret in
a proxy tool's env) and the undeclared-secret collection were written
three times: tool env and agent env in `resolve_wire_config`, and
`layers::resolve_env_table` in the launcher. The copies had drifted on
how they name the entry: the daemon reported `[tools.gh.env.X]`, the
launcher `[gh.env.X]`, for the same undeclared label.

`check_env_entry` now holds the checks and `EnvOwner` names the table,
so both sides render the same location. The daemon still re-validates
everything the launcher sent; it just does it through the same function.
`airlock init` worked out `$XDG_CONFIG_HOME/airlock/airlock.toml` itself
from the raw variable, while every other command goes through
`anchors::resolve`, which treats an empty `XDG_CONFIG_HOME` as unset. With
`XDG_CONFIG_HOME=""`, `init --global` wrote a relative
`airlock/airlock.toml` under the cwd that nothing else ever reads.

`init --local` also read `airlock.toml` and the global config with plain
`read_to_string`, skipping `read_config_securely`'s O_NOFOLLOW, ownership
and size checks that loading a layer applies to the same files.

`anchors::global_config_path` now owns the path and `init` is handed its
result; reads go through `LayerFile::read`/`parse`.
Four functions created a "0700 directory" with four slightly different
results. `trust::ensure_dir` ran `create_dir_all` and only then
`set_permissions(0o700)`, so the trust store's directory briefly existed
at the umask's mode, and its missing parents stayed there. The trust
store dir created via anchors got 0700 on the leaf only, while the
launcher's and `init`'s versions made every created parent 0700 too, and
the launcher's also accepted an existing *file* at the path.

`anchors::create_private_dir_all` now does it once: every directory it
creates is 0700 from the moment it exists, and anything other than an
existing directory is an error. `runtime_dir::ensure_dir_0700` stays
separate on purpose; the runtime base must never create its parents.
…able

`resolve_command_argv0` canonicalized a `/`-containing `argv[0]` with
`canonicalize(..).unwrap_or(candidate)`, so a path that didn't resolve —
a dangling symlink, say — was location-checked in its unresolved form
and, if that looked fine, handed to spawn. A symlink's own location can
pass while the target it later resolves to is inside the project, which
is exactly what `exec::probe_executable` exists to rule out for a `PATH`
hit ("there is no safe fallback to the unresolved path").

The slash case now goes through `probe_executable` too and fails with
`NotExecutable` when it returns nothing.
Each handler wrote its replies by calling `write_ndjson_message` with
the writer and both redactors spelled out by hand: twelve copies of a
twelve-line `Error` block and sixteen more sends, each choosing for
itself which redactors to pass. Redaction was only as mandatory as every
call site remembered to make it.

`Responder` now owns the writer, the daemon-wide redactor and, once a
session is in scope, that session's own, so `send` and `error` always
apply both. The exec path sets its session redactor up front and swaps
in the global snapshot taken alongside the tool's secrets once it
starts, as before.
Every `cmd_*` in main.rs turned each failure into output and an exit
code on the spot: 49 `ExitCode::from(125)` and 18 `match .. { Err(code)
=> return code }` blocks, with some paths printing through `eprint_error`
(escaped) and others through a bare `eprintln!("error: {e}")`.

Commands now return `Result<ExitCode, CliError>` and use `?`. `CliError`
converts from `LauncherError`/`RunError` (where `Aborted` means already
reported), `AdminError` (exit code from the daemon's `ErrorKind`) and
`RuntimeDirError`, and `finish` in `main` is the one place that prints
it, so every message gets the same terminal escaping. `main()` stays
synchronous.

`cmd_tools`'s own "--session needs your terminal" check is dropped: it
could never run, since `sandbox_refusal` refuses that command first.
`read_admin_token` lived in launcher.rs, away from both the daemon code
that writes the token and the admin client that sends it, and "locate,
connect, read the token" was spelled out by hand in main.rs, client.rs
and the launcher. `ClientHello` was likewise built field by field in
both the admin and session clients.

`admin::read_admin_token` and `admin::connect_with_token` now hold that
sequence, with token errors as `AdminError` variants, and
`ClientHello::current()` builds the hello.
`Session` was built as a struct literal in three places: register, the
session tests and a daemon test. Each spelled out its defaults (no
execs, a full `EXEC_CAP` semaphore, a fresh lease closer), so adding a
field meant updating all three. Register and reload also each wired the
same weak-reference refresh callback before calling
`build_session_policy`, and "now + ttl" and the expiry check under the
`ends` lock were each written twice.

`Session::new` (which also mints the token), `DaemonState::build_policy`,
`Ends::ttl_from_now` and `Session::is_expired` now hold those.
Ending a session (revoke, expiry, lease close) and stopping the daemon
each cancelled the lease and removed the session's CA file by hand, and
reload removed the CA file a third way; the "why unconditionally" note
was written out at all three.

`release_session` and `remove_session_ca` now hold that, with the
rationale once. `end_session` still spawns the refresh shutdown and
`graceful_shutdown` still awaits it.
Session setup, the global redactor and a refresh each built a redactor
the same way: lock every slot, clone the `Arc` values into an owned Vec,
build a second Vec of borrowed pairs, then call `Redactor::new` or its
near-identical twin `build_from_generations`. The constructor only
copies bytes into patterns, so the intermediate copies bought nothing.

`RedactorBuilder::add` registers a value's four encoded forms as it is
added, so callers add straight from under the slot lock.
`build_from_generations` goes, and `redact_bytes`/`redact_stream` pass
the replacement strings to aho-corasick directly instead of allocating
a fresh `Vec<&[u8]>` per call, which the proxy's per-chunk
`StreamRedactor::push` paid on every chunk.
Seven kit defaults (fnm, yarn, pnpm, corepack, pip, poetry, go-build) each spelled out the same `match inputs.platform { MacOs => home.join(..), Linux => home.join(..) }`. `Inputs::home_path` now takes the two relative paths.
Fifteen places in the macOS backend escaped a path and formatted it into one `(allow|deny <ops> (literal|subpath ".."))` rule by hand. `push_rule` does both, so a path can't reach the profile without `escape_path`'s control-character check and quoting. The emitted SBPL is unchanged.

The Linux backend's duplicated baseline lists are left alone: this toolchain has no Linux target, so changes there couldn't be compiled before reaching CI.
- `ExecError::BinaryNotFound`, `SecretsError::{InvalidUtf8,
  CommandFailures}` and `ConfigError::NotFound` were never constructed
  outside their own display tests.
- `ToolPolicy::tmpdir` was set by the daemon but read by no backend; a
  test existed only to pin that it was ignored.
- `layers::local_stub_for_unbound` had no callers.
- `clone_classified` hand-wrote a `Clone` impl for `ClassifiedSecret`,
  whose fields all derive `Clone`.
- main.rs's `layers_changed_on_disk` was a byte-for-byte copy of
  `client::layers_changed`.
- `client::read_responses` took a `tool` argument it only discarded.
`handle_exec_request` was about 270 lines: exec-slot acquisition, tool
start, the 70-line I/O select loop and the termination report in one
function. It also released the tool's child-registry entry and its exec
slot with two trailing lines, which a panic or any future early return
between spawn and the end would skip, leaking a slot from the session's
cap of 16 for good.

`pump_io` now owns the select loop and returns why it stopped, and a
`RunningChild` guard releases the registry entry and the slot on drop.
The loop's behaviour, including the order of redaction and stdin
handling, is unchanged.
`merge` listed the three layers by hand at each step and repeated
several pieces outright: the repo and global "no `override`" loops, the
scope-and-pool selection for a tool's env and again for the agent's,
"highest layer wins" for `timeout` and again for `access`, and five
"union, first-seen wins, remember the layer" loops that each kept a
plain Vec alongside a provenance Vec holding the same strings.

The layers are now listed once, lowest to highest, and `reject_override`,
`LayerPools::for_layer`, `highest_wins` and `union_with_layer` hold the
rest. The order of merged lists and the provenance the `airlock config`
tests pin are unchanged.
55 config tests set `$HOME` through `TempEnvVar` before loading, but the
loader they call passes a fixed decoy home explicitly and nothing on that
path reads the environment. Each guard still took the crate-wide
`ENV_MUTEX`, serializing those tests against every env-touching test in
the suite for no effect. With the guards gone, `TempEnvVar` has no users
left either.
The daemon tests connected a client to the test daemon's socket by hand
52 times, asserted on an `Error` reply's kind with the same 12-line
`matches!` block 12 times, hand-rolled a deadline poll loop 4 times, and
built two full `RegisterPayload` literals that differed from
`test_payload` only in their secrets.

`TestDaemon::client`, `assert_error`, `wait_until` and `with_secret`
replace them. One poll loop that does real work in its body stays as it
was.
Twelve layers tests loaded the default layers and merged them with the same two lines; `merge_default` does both.
@paveq

paveq commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Code review findings

I reviewed the local feat/airlock-v2 at 141213a, which is 26 commits ahead of the pushed head (1d26f63), so the line numbers below refer to that local state. These findings come from reading the code; I didn't build or run tests. The stray build.log in the pushed head is already removed locally.

Security and isolation (fix before merging)

  1. A global tool can be given the repo's secret (src/layers.rs:1200). If the global layer and airlock.toml both declare the same label, final_secrets keeps only the repo/local binding, because final_secrets.contains_key skips the global spec. A global gh tool that references GH_TOKEN then gets whatever value the repo bound to that label (for example source="env", from="AWS_SECRET_ACCESS_KEY") and sends it to GitHub. The trust review never shows that the global tool was rebound. This contradicts the design rule that the two layers are "separate bindings until the local file links them".
  2. Refreshed secret commands run unsandboxed with cwd = project root (src/session.rs:231). argv[0] is location-checked, but anything the command loads relative to its cwd is not. Example: with command = ["python3", "-m", "keyring", "get", …] and refresh = 3600, the sandboxed agent writes <root>/keyring.py. The next background refresh runs it as the user, outside every sandbox. The same applies to npx, uv run and mise shims that read project config from cwd.
  3. Pre-auth connections can buffer 16 MiB each (src/daemon.rs:1255). Every connection is framed with MAX_ADMIN_LINE_BYTES. MAX_SESSION_LINE_BYTES is only checked after the full line is buffered, and never for later stdin frames. Any process of the uid, including an agent sandboxed for another project with no token, can open many connections and grow daemon memory without bound. Session clients can also send 16 MiB stdin frames. v1 capped every line at 1 MiB via ClientLineOverflow.
  4. handle_reload doesn't check that payload.root == session.root (src/daemon.rs:1625). The agent deletes <proj>/airlock.toml, then airlock session reload runs discovery, which walks up to an already-trusted parent (e.g. ~/src/airlock.toml). Every tool then gets read-write on ~/src as its sandbox root, while the cwd check and resolve_binary_in still use <proj>.
  5. Secret values pass through buffers that are never zeroized (src/daemon.rs:1295). Register/Reload carry plaintext secret values, but the daemon reads them into a plain String (plus LinesCodec's BytesMut), and the launcher's encode_line writes them into a plain Vec<u8>. Only WireSecret.value is Zeroizing. Every registration leaves copies of every resolved secret in freed heap, in both the launcher and the long-lived daemon.

Reliability

  1. A reused PID blocks daemon auto-start (src/daemon.rs:493). The stale-state check still treats a live PID in airlock.pid as a running daemon. It runs while holding the exclusive startup lock, and a real daemon holds that lock for its whole life, so a live PID here can only belong to an unrelated process that reused it. After a SIGKILL, crash or OOM, airlock run and session start return AlreadyRunning until that process exits or the user deletes the PID file.
  2. The listener stays bound during graceful_shutdown (src/daemon.rs:997). Shutdown can take 5s or more (child grace period plus 2s per session). New connections queue in the backlog and are never answered. airlock run connects, waits for the 10s Hello timeout and fails as a hard error rather than Unreachable, so it never starts a fresh daemon. airlock exec has no read timeout and hangs until the old daemon exits. Fix: drop the listener before graceful_shutdown.
  3. daemon restart always starts a Manual daemon (src/main.rs:1213). With a launchd/systemd service installed, the service manager respawns the daemon first and takes the startup lock, so restart's own start fails with StartInProgress (exit 75). Restarting an automatic daemon turns it into a Manual one that never exits when idle.
  4. parse_ttl can panic (src/launcher.rs:953). split_at(len-1) panics on a non-ASCII last character (--ttl 5é), and n * multiplier is unchecked. A large value that doesn't overflow still panics in the daemon's Ends::ttl_from_now (SystemTime overflow in handle_register/handle_renew), and the launcher sees the connection drop with no error message.

Error message

  1. run_trust discards the refusal message (src/launcher.rs:865). It matches Decision::Refuse { .. } and drops the message from trust::decide. On an --expect-sha256 mismatch the user never sees which file failed or its current hash. The CLI prints a generic hint instead, which on an interactive terminal wrongly suggests a non-terminal is needed.

A workspace of repos (~/work/api, ~/work/web, ...) had two ways to share
tools and credentials: copy them into every repo, or start every agent
from ~/work, which makes the whole workspace its sandbox root and lets the
agent write every sibling repo. A directory's config can now apply to the
projects below it, the way mise.toml files stack, while each project
stays its own sandbox root.

- The parent opts in with `cascade = true`. The flag is part of the
  approved bytes, so a file approved earlier as a project root never
  starts reaching other projects without a new approval. A project, or a
  parent further down, opts out with `inherit = false`.
- Merging is a fold ([merge](src/layers.rs)): the global file is the first
  base, each parent's own files merge onto it and become the next base,
  and the project merges last. Everything below a parent treats it as it
  treats the global layer: a project tool replaces a parent tool, a repo
  item cannot reference a parent's label, and a repo label reaches a
  parent binding only through an approved local `from = "parent"` line.
- Relative paths in a parent resolve against the parent's directory;
  `{sandbox_root}` and `{tool_state}` still mean the project root, so
  parent env values render once, at the project.
- Parent files are approved in the trust-store slot of their own
  directory, once for every project below them. `airlock trust` names
  sessions in any of those projects that still run the previous version.
- `init --local` stubs `from = "parent"` for labels a parent binds.

The daemon is untouched apart from the new `parent` layer kind on the
wire, hence the protocol bump to 4. The worked example is in
examples/workspace/, loaded by tests/examples_parse.rs.
@paveq

paveq commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Known issue: secret labels share one namespace across layers

This predates parent configs (4ac91ff). That change extends it to parent-level tools, so I'm recording it here.

On the wire, a merged config's [secrets] table is one flat map keyed by label. A tool from a lower layer that references label L gets whatever binding the final table holds for L. If a higher layer also declares L, that is the higher layer's binding, not the one from the layer that declared the tool.

  • Global tool, repo label. The global file declares [secrets.GH_TOKEN] and a global gh tool that uses it. The repo also declares [secrets.GH_TOKEN], with its own source or bound in the local file. The global gh then runs with the repo's GH_TOKEN, not the user's global one.
  • Parent tool, repo label. The same happens to a tool from a cascade = true parent config when the project declares a label of the same name.

merge_level in src/layers.rs inserts the repo and local labels into the final secrets table first. When it then adds the base labels that base tools reference, it skips any label the table already has.

Impact. It doesn't leak a personal secret into a repo tool. The direction is the reverse: an approved repo file decides which credential a user's own global or parent tool receives. That needs an approved file, and usually a project that declares a label for its own tool of the same name, so it's mostly a correctness surprise rather than a hole. It's still a case where a lower layer's tool silently runs with a binding its own file never declared.

Possible fixes (not done in this PR):

  • Make the collision a config error that names both files when a base tool references a label a higher layer also declares.
  • Namespace labels per layer on the wire, so each tool's env refers to the binding of the layer that declared the tool.

Left alone for now; I'll address it separately if wanted.

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