Repository navigation
Conversation
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.
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.
Code review findingsI reviewed the local Security and isolation (fix before merging)
Reliability
Error message
|
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.
|
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. |
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