Repository navigation
feat: implement Airlock v2 (sessions, layered config, trust) - #18
Conversation
Code review findingsI reviewed the local Security and isolation (fix before merging)
Reliability
Error message
|
|
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
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):
Left alone for now; I'll address it separately if wanted. |
dd3518f to
379b81a
Compare
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.
`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.
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.
A program the agent starts doesn't know about `airlock exec`. A CLI that runs `git fetch` itself gets the agent's own credential-less `git` and fails, and the only fix so far was teaching each program a wrapper command. With `shim = true` on a tool, `airlock run` writes a small `sh` script per shimmed tool that runs `airlock exec -- <tool> "$@"` with this binary, and puts their directory first on the agent's PATH. A plain `git` then goes through the session, whoever runs it. No new capability: the agent already holds its session token. The daemon still resolves the real binary on the session's filtered PATH, which was built before the shim directory existed, so a shim can't find itself. What does change is how often the tool runs with its secrets: every call, `git status` included. SECURITY.md says so, and subcommand routing is noted in TODO.md. `tools list` and the SessionStart hook mark shimmed tools, so the agent knows a plain name already reaches Airlock. They report a tool as shimmed only for `run`'s own sessions, since `session start` writes no shims. The shim names join the agent hash because the scripts are fixed at launch. ToolInfo gained a field, hence protocol version 5.
The shims lived under `$XDG_CACHE_HOME/airlock/shims`, which persists across reboots, so a launcher killed before its own cleanup left its directory there for good. The runtime dir is the better home: per-boot, mode 0700, and writable by no agent or tool sandbox. The scripts stay as written, the same way a proxy tool's CA file next to them does. It also lets the daemon clean up. `release_session` already removes a session's CA file when the session ends, and now it removes the session's shims too. A killed launcher closes its lease, so its shims go as soon as the daemon notices.
The agent profile allowed read, write and ioctl on every `/dev/ttys*`. On macOS every terminal tab is one of those, owned by the same uid, so an agent could open another tab, fake its output, or send it escape sequences that make the terminal emulator answer into that tab's input. Writing zero bytes to /dev/ttys000 from inside the sandbox showed this. `airlock run` now looks up the devices behind its own stdin/stdout/stderr with `ttyname`. The profile grants exactly those plus `/dev/tty`. Writing to the inherited fds needs no rule; raw mode, window size and reopening the terminal by name still work through the new grants. The broad patterns only existed so the agent could allocate ptys, and no literal can name a pty in advance, so that ability moves behind `[agent] pty = true`, documented as reopening every tab. `TIOCSTI` into the agent's own terminal was already refused, but no rule of ours named it, so the profile now denies it explicitly after every ioctl allow. When the agent exits, `run` flushes the terminal's pending input, so a reply the agent got the terminal to send isn't read by the shell as typed. That's cleanup, not a boundary: a process the agent leaves behind still holds the terminal. SECURITY.md has the details.
Narrowing the sandbox to the agent's own terminal left that terminal shared. A process the agent leaves running keeps the descriptor after the agent exits. It can write into the terminal while the user's shell is in charge again, and read what the user types next, a `sudo` password included. Escape sequences that make the terminal reply put the reply in the terminal's input, and one provoked just before exit is read by the shell as typed. Discarding pending input at exit couldn't fix either: the leftover process outlives the discard, and a late reply arrives after it. `airlock run` now does what `ssh -t` and `script` do. It allocates a pty, makes its slave the agent's stdio and controlling terminal in a session of its own, and relays bytes between the master and the real terminal, which stays in raw mode meanwhile. When the agent exits, it forwards output still in flight. It then sends a status query (`ESC [5n`) and discards input up to the answer: terminals answer in order, so every reply the agent provoked comes first. Last, it closes the master, so a leftover process holds a pty nothing reads or writes. The sandbox profile grants exactly that slave. The end-to-end test runs a shell as session leader of a test pty, with `airlock run` in its foreground. It fails both with the drain removed (the reply is left for the shell) and with the relay removed (the leftover process's write reaches the terminal). SECURITY.md describes the threats, the relay, and the escape sequences that still reach the terminal while the agent runs.
With the relay, replies to escape sequences no longer reach the user's shell, but some sequences act on the terminal itself. A clipboard write (`OSC 52`) can plant `rm -rf ~` plus a newline for the user to paste, which runs the moment it's pasted. iTerm2's tmux integration (`DCS 1000p`) hands the rest of the stream to a parser that has had remote-code-execution bugs. The relay now runs the agent's output through a streaming filter. Clipboard writes keep their text but not what can act on paste: each run of line breaks and tabs becomes one space, and other control characters and bidi controls are removed. Clipboard reads and `DCS 1000p` are dropped. The parser abandons a string where xterm-style terminals do, so a clipboard write hidden in a window title is still caught. Where it could disagree with a terminal, it drops rather than passes. The relay and the filter can each be turned off, and `pty` moves next to them, under `[agent.terminal]`. Each one weakens a protection of the user's own terminal, so like `[kits.*]` they're refused in a repo's `airlock.toml`. With `relay = false` the sandbox grants the inherited terminal device again, as before the relay.
The runtime base sits under a temp dir the OS sweeps by age. macOS `dirhelper` deletes files under `_CS_DARWIN_USER_TEMP_DIR` that have gone 3 days without being touched. The daemon writes `admin.token` and `airlock.pid` once, at startup. A daemon running for more than 3 days therefore lost both: `daemon logs`, `daemon stop` and new `airlock run` launches all failed, and the only way to recover was to kill the daemon along with every live session. Long-lived sessions' CA files and shims have the same exposure, as does the Linux `/tmp` fallback under systemd-tmpfiles. The serve loop now refreshes the timestamps of everything under the base every hour ([touch_tree](src/runtime_dir.rs)). The interval is far below the threshold because tokio's monotonic clock doesn't advance while a Mac sleeps.
The Landlock runtime-base isolation test listed every `AgentPolicy` field, so the `pty` and `terminals` fields added for the agent's own terminal broke it. The test only compiles on Linux, which is why macOS builds missed it. It now defaults the fields it doesn't care about, as its neighbours do. Also fixes the `nonminimal_bool` lint in the lease-EOF daemon test, which fails `cargo clippy --all-targets` under `-Dwarnings`.
2c64e8e to
5760231
Compare
`run_relays_the_terminal_and_leaves_the_shell_nothing` hung forever on Linux CI. Its terminal loop reads the pty master until EOF. The spawned `Command` kept its copies of the slave fds for the whole loop, and Linux reports EOF on the master only once every slave fd is closed. macOS hangs the pty up when the session leader exits, so the test passed there.
A test that hangs, like the pty relay test did on Linux, otherwise holds the runner until GitHub's 6-hour default. A full run takes about 2 minutes, so 15 leaves ample headroom.
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).
airlock daemon install).Register.admin.tokenor a session token), with anErrorKindper failure mapped to exit codes 125/126/127.airlock runlease closes, when their TTL runs out (session start, 12h), on revoke, or when the daemon stops.Runtime dir outside the project (src/runtime_dir.rs):
confstrper-user temp dir on macOS,/run/user/<uid>or/tmp/airlock-<uid>on Linux. It is never taken from the environment and is validated withlstat. Every Seatbelt profile ends with denies for writes to it and for readingadmin.token, plus F9's.git/hookswrite deny.Layered config (src/layers.rs, src/config.rs):
airlock.toml< localairlock.local.toml.override = true).from = "global"reuses a global binding.{tool_state}keeps tool state outside the project.deny_unknown_fieldsapplies at every level, so the wire config fails closed.Trust (src/trust.rs):
from = "global"lines are annotated with the global source.airlock trusttakes--yesand--expect-sha256.Anchors and B2 (src/anchors.rs, src/exec.rs, src/secrets.rs):
--allow-write.PATHis used both to resolve tools and to resolve secret commands'argv[0].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):
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.--helpis grouped by audience; inside the sandbox it shows only the commands that work there and names the hidden ones.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):systemplus read-only Nix store, Homebrew,/usr/local, MacPorts and Linuxbrew, so dynamically linked Nix and Homebrew tools work withoutextra_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--kitadds 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.CARGO_HOME,npm_config_cache,GOPATH,MIX_HOME, …) at a per-project directory under~/.cache/airlock/kits/.~/.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.tomldisallowsstd::env::var*,set_var,remove_varandcurrent_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, andexamples/secret-sources.tomlreplaces 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
Run it from a normal terminal, not inside
airlock run. It shows:init --local.configwith each item's layer.trustandsession start.exec, and exit codes 127/125/3.trustandsession reload.agent checkand the SessionStart hook output.airlock runwith a sandboxed agent.daemon stop.Testing
cargo testpasses all 945 tests, none skipped, including every test that applies a real Seatbelt sandbox.examples/demo/demo.shruns end to end with exit 0.execwith stdin at EOF (/dev/null, an empty pipe) failed: the client half-closed the socket, which the daemon reads as a disconnect.airlock runsession ended as soon as it was registered: the daemon half-closed the lease afterRegistered.airlock runsession 10 seconds in.cargo fetch/buildruns underairlock run:~/.cargountouched;config.toml;config.toml,credentials.tomlor~/.cargo/bin.build.rssetsno_nested_sandboxwhenAIRLOCK_SANDBOX=1orAIRLOCK_NO_NESTED_SANDBOX=1(the Nix package build sets the latter). A plaincargo testanywhere else runs everything.main, so this PR's later commits were checked with the cross-checked clippy below, not run on Linux.cargo clippy --all-targets -D warningsis clean on macOS and on Linux (cross-checked).Beyond the design
/dev/null. The Landlock baseline used to grant it read-only, which broke2>/dev/nullin tools and agents.$HOMEthrough withoutallow_home_root.airlock configescape config text.[A-Za-z0-9._+-].argv[0], not the canonical binary path, so multicall binaries (Nix coreutils, busybox) work.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.🤖 Generated with Claude Code