Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
119 commits
Select commit Hold shift + click to select a range
7e4b989
feat(trust): add the config trust store, review rendering and prompts
paveq Oct 6, 2026
cf83503
feat(runtime): add runtime_dir, anchors and process_tree modules for v2
paveq Oct 6, 2026
73be2fe
feat(protocol): rewrite the wire protocol for v2 sessions
paveq Oct 6, 2026
a5e5634
feat(exec,secrets): filtered PATH and session-scoped secret commands …
paveq Oct 6, 2026
0810977
feat(sandbox): carve the runtime base out of every profile, add F9 gi…
paveq Oct 6, 2026
fd826b4
feat(config): layer airlock.toml across global/repo/local config files
paveq Oct 6, 2026
9beb324
refactor: share hashing, layer kinds and path containment across the …
paveq Oct 6, 2026
bdb3c1e
build: ignore sandbox-applying tests where sandboxes cannot nest
paveq Oct 6, 2026
0359de9
feat(service): add airlock daemon install/uninstall
paveq Oct 6, 2026
ed4d6b6
feat(cli): render airlock config/status and write airlock init
paveq Oct 6, 2026
804b7ae
docs: rewrite top-level docs for Airlock v2
paveq Oct 6, 2026
432fd12
docs(examples): update examples for v2, add team layering walkthrough
paveq Oct 6, 2026
4702f8d
feat(session): add the multi-session registry and per-session policy
paveq Oct 6, 2026
a14c2e4
refactor(refresh): run secret refresh under a session's CommandContext
paveq Oct 6, 2026
1fe2b9e
feat(daemon): rewrite the daemon as a multi-session server
paveq Oct 6, 2026
26819be
test: port daemon e2e/integration tests to the v2 protocol
paveq Oct 6, 2026
e076f20
feat(admin): add the synchronous v2 admin client
paveq Oct 6, 2026
e40aec2
feat(launcher): add the v2 session launcher pipeline
paveq Oct 6, 2026
8dc92c0
feat(run): rewrite airlock run for v2 sessions and leases
paveq Oct 6, 2026
1b7149d
feat(client): rewrite exec/tools-list for the v2 protocol
paveq Oct 6, 2026
8018771
feat(cli): rewrite the command surface for v2
paveq Oct 6, 2026
f11d56e
test: port CLI/run/daemon integration tests to v2
paveq Oct 6, 2026
8687f80
test: use cfg_attr(no_nested_sandbox) for the run integration test
paveq Oct 6, 2026
18df87a
test: run the v2 daemon and launcher suites together
paveq Oct 6, 2026
f17f9ce
fix(admin): open a connection per admin request
paveq Oct 6, 2026
7e08a78
fix(sandbox): import the Linux baselines in the runtime-base test
paveq Oct 6, 2026
5d76ae2
fix(service): compile the plist escaping on macOS only
paveq Oct 6, 2026
fb97799
fix(config): compare the project root with the canonical home
paveq Oct 6, 2026
daeb5ff
test: keep the example project below the stand-in home
paveq Oct 6, 2026
d8805b8
fix(daemon): word the outside-the-project error as the UX does
paveq Oct 6, 2026
005267b
fix(inspect): show session start times in local time, not UTC
paveq Oct 6, 2026
1355862
feat(cli): wire config, status, init, daemon install/uninstall, and g…
paveq Oct 6, 2026
cdf8715
test: cover config, status, init --local, and the grouped/sandboxed help
paveq Oct 6, 2026
42708c4
feat(agent): add `airlock agent check` and `airlock agent hook`
paveq Oct 6, 2026
137bfde
feat(cli): wire airlock agent check and agent hook
paveq Oct 6, 2026
ae54634
fix(launcher): create {tool_state} directories recursively
paveq Oct 6, 2026
dcb38a0
docs(examples): add a runnable end-to-end v2 demo
paveq Oct 6, 2026
fc8909f
refactor: enforce session isolation with a clippy disallowed-methods …
paveq Oct 6, 2026
b3c1cba
docs: fix v2 drift in README/SKILL/ARCHITECTURE/SECURITY/CLAUDE
paveq Oct 6, 2026
0cfe715
docs(design): record implementation notes and trust-store file modes
paveq Oct 6, 2026
4efc728
docs(todo): fix stale file:line references in the security backlog
paveq Oct 6, 2026
eb6681d
fix(sandbox): let Linux sandboxes write to /dev/null
paveq Oct 6, 2026
4bd96ea
fix(inspect,client,agent): escape untrusted config text before printing
paveq Oct 6, 2026
e4e7c0d
fix(anchors,launcher): refuse a {tool_state} dir inside the root or a…
paveq Oct 6, 2026
7d60035
fix(run): forward SIGINT to the agent, not just SIGTERM/SIGHUP
paveq Oct 6, 2026
e563262
refactor: delete dead v1 protocol and config-loading code
paveq Oct 6, 2026
b838c6f
feat(launcher): print the project/trust-state line under -v
paveq Oct 6, 2026
76d0628
fix(main): match the design's wording for the reload agent-changed note
paveq Oct 6, 2026
035ccff
fix(main): hide init --global and fix spacing in sandboxed help
paveq Oct 6, 2026
21da7f1
fix(agent): give the hook's External/config-changed cases the full pr…
paveq Oct 6, 2026
d80f466
docs(ux): the hook's external and config-changed outcomes keep the pr…
paveq Oct 6, 2026
a8b3d04
fix(daemon): redact every Error message through write_ndjson_message
paveq Oct 7, 2026
ce3bfba
fix(main): escape terminal-hostile text in pre-trust error output
paveq Oct 7, 2026
6f3c4a4
fix(exec): never resolve a binary to an unresolved candidate path
paveq Oct 7, 2026
e5121fa
fix(daemon): serialize concurrent daemon starts with a startup lock
paveq Oct 7, 2026
8acfefe
fix(main): resolve session refs exactly, not every match, for reload/…
paveq Oct 7, 2026
2f49fae
fix(session): fix proxy CA file lifecycle across reload and session end
paveq Oct 7, 2026
7275153
fix(daemon): base idle-exit on sessions, not on merely being connected
paveq Oct 7, 2026
4776907
docs(security): document Error-message redaction in the output section
paveq Oct 7, 2026
bce8af3
fix(cli): report revoked sessions by id and name again
paveq Oct 7, 2026
f705318
fix(client): keep the exec connection open after stdin reaches EOF
paveq Oct 7, 2026
3795363
fix(daemon): stop half-closing the lease connection after Registered
paveq Oct 7, 2026
82e3816
fix(run): hold the lease without the admin request timeouts
paveq Oct 7, 2026
48cf6df
test: stop the daemon a launcher fixture started when the test ends
paveq Oct 7, 2026
e351aa2
test(sandbox): use a socket probe macOS's nc can answer
paveq Oct 7, 2026
e83007d
docs(demo): let the sandboxed agent run the dev build in step i
paveq Oct 7, 2026
08385b4
fix(exec): run tools with their own name as argv[0]
paveq Oct 7, 2026
a0d42d5
docs(readme): explain dylib denials for Nix and Homebrew tools
paveq Oct 7, 2026
4f6504a
feat(config): add the [kits.*] and agent.kits schema
paveq Oct 7, 2026
0fac8fc
feat(layers): validate and merge [kits.*] and agent.kits across layers
paveq Oct 7, 2026
f2589c4
feat(kits): built-in kit definitions and pure expansion
paveq Oct 7, 2026
0cb512d
feat(launcher,run): apply kits to the airlock run agent sandbox
paveq Oct 7, 2026
04ac0f2
feat(inspect): show active kits in airlock config
paveq Oct 7, 2026
300df2a
test(run): sandboxed integration tests for the rust kit
paveq Oct 7, 2026
7bdd85e
docs: document kits across the design doc, README, SKILL, ARCHITECTUR…
paveq Oct 7, 2026
8716ee7
fix(kits): let shared-mode cargo write its global-cache journal
paveq Oct 7, 2026
b99159f
feat(sandbox): add per-tool filesystem access levels
paveq Oct 7, 2026
01f0282
feat(config): add tools.*.access and top-level access settings
paveq Oct 7, 2026
0c718c2
feat(inspect): show effective tool access in airlock config
paveq Oct 7, 2026
82ff163
docs: document the tools.*.access config surface and its tradeoffs
paveq Oct 7, 2026
6590d36
fix(sandbox): grant a Linux tool its own binary at every access level
paveq Oct 7, 2026
d49d59b
feat(kits): apply the user's cargo config in shared rust mode
paveq Oct 7, 2026
89c7a4a
feat(kits): let shared kits read registry credentials
paveq Oct 7, 2026
c4f4411
chore: drop stray build.log and stale v1 references
paveq Oct 7, 2026
2344d96
fix(session): never let a reload shrink a session's write grants
paveq Oct 7, 2026
965d9a9
fix(daemon): redact the global last pass as a stream, with previous v…
paveq Oct 7, 2026
efd9eb2
fix(sandbox): refuse a double quote in paths bound for SBPL regex rules
paveq Oct 7, 2026
9c0fa21
fix(kits): derive a built-in kit's env names from its own expansion
paveq Oct 7, 2026
c0fa3ec
fix(cli): resolve `session renew`'s session by the daemon's own rule
paveq Oct 7, 2026
ac60ad4
refactor(cli): route admin requests through typed helpers
paveq Oct 7, 2026
14abb16
fix(trust): validate kits the way a launch does before approving
paveq Oct 7, 2026
9e0669c
refactor(config): check env entries in one place, launcher and daemon…
paveq Oct 7, 2026
c8fb0bb
fix(init): use the global config path and file checks every load uses
paveq Oct 7, 2026
c53b358
refactor: create private directories through one helper
paveq Oct 7, 2026
a77ce06
fix(secrets): refuse a path argv[0] that doesn't resolve to an execut…
paveq Oct 7, 2026
0c4fbc0
refactor(daemon): send every reply through one Responder
paveq Oct 7, 2026
1b0234c
refactor(cli): return CliError from commands and print it in one place
paveq Oct 7, 2026
9bf5a00
refactor(admin): own admin.token reading and the connect-then-auth step
paveq Oct 7, 2026
5c83e9e
refactor(session): construct sessions and their policies in one place
paveq Oct 7, 2026
42f2f54
refactor(daemon): release an ended session's resources in one place
paveq Oct 7, 2026
12aa28f
refactor(redact): build redactors through one RedactorBuilder
paveq Oct 7, 2026
90d2e78
refactor(kits): resolve per-platform default paths through one helper
paveq Oct 7, 2026
b287d48
refactor(sandbox): emit single-path SBPL rules through push_rule
paveq Oct 7, 2026
f1bcd59
chore: remove dead code and hand-copied helpers
paveq Oct 7, 2026
d935162
refactor(daemon): split exec I/O out of handle_exec_request
paveq Oct 7, 2026
d95cc0f
refactor(layers): share the per-layer helpers inside merge
paveq Oct 7, 2026
5fc2642
test(config): drop the HOME guards that guarded nothing
paveq Oct 7, 2026
a869636
test(daemon): collapse repeated test setup into helpers
paveq Oct 7, 2026
473b38a
test(layers): load and merge through one helper
paveq Oct 7, 2026
9b0fc8c
feat(layers): stack parent configs that set cascade = true
paveq Oct 7, 2026
48515a8
feat(run): shim tools onto the agent's PATH with `shim = true`
paveq Oct 7, 2026
ae522c3
refactor(run): keep tool shims in the runtime dir
paveq Oct 7, 2026
eaf2178
fix(sandbox): give the agent its own terminal, not every tab
paveq Oct 7, 2026
b0f8ab3
feat(run): relay the agent's terminal through a pty of its own
paveq Oct 7, 2026
4d552c6
feat(relay): filter clipboard writes and iTerm2 tmux integration
paveq Oct 7, 2026
0256a71
fix(daemon): keep runtime dir files from aging out of the OS temp dir
paveq Oct 21, 2026
5760231
fix(test): restore the Linux build and clippy on all targets
paveq Oct 21, 2026
8e533d4
fix(test): close the test's copies of the pty slave in the relay test
paveq Oct 21, 2026
f54b43c
ci: cap the test jobs at 15 minutes
paveq Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
name: Test
runs-on: ubuntu-latest
needs: check
# A hung test otherwise holds the runner for GitHub's 6-hour default.
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
Expand All @@ -39,6 +41,7 @@ jobs:
name: Test (macOS)
runs-on: macos-latest
needs: check
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
Expand Down
469 changes: 217 additions & 252 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,27 @@ Read [README.md](README.md), [ARCHITECTURE.md](ARCHITECTURE.md), and [SECURITY.m

- **`main()` is synchronous.** No `#[tokio::main]`. Daemonization forks; forking after tokio spawns runtime threads leaves them in undefined state in the child. The entire `synchronous_startup()` must complete before any tokio runtime exists. See [src/daemon.rs:14-19](src/daemon.rs#L14-L19).
- **Double-fork with readiness pipe.** `daemon start` returns only after one of two things: the grandchild signals that it accepts connections, or `daemon start` prints the grandchild's startup error and exits non-zero. Every startup step in `async_main` that can fail runs before the readiness signal and must send its error through the pipe. It must never fail silently. See `daemonize` and `ReadinessPipe` in [src/daemon.rs](src/daemon.rs).
- **Trust boundary is the Unix socket.** The daemon is trusted; the client is not. Socket is mode `0700` and verified post-bind ([src/daemon.rs:418-430](src/daemon.rs#L418-L430)) — refuse to start if filesystem doesn't honor it.
- **The trust boundary is the session token, not the socket alone.** One daemon serves every project a user has; the socket is reachable by any process of that uid, including an agent sandboxed for a *different* project. A connection proves nothing until it presents a session token, minted only by a launcher holding `admin.token`. The runtime directory is still mode `0700`, verified post-bind/post-create in [src/runtime_dir.rs](src/runtime_dir.rs) — necessary, but never sufficient on its own; never authorize a request from the socket connection alone.
- **Session isolation is structural, not a matter of care.** Hold these in every change touching `daemon.rs`, `session.rs` or a request handler:
- **A session handle is the only path to that session's secrets, config and policy.** A handler takes `Arc<Session>` (resolved from the token before the handler runs) and reads only through it — no global map keyed by secret label or project root, no lookup outside its own session.
- **No `std::env::var`/`set_var`/`remove_var`/`current_dir` outside startup code, ever, once a session is registered.** Everything a request needs travels with `Register`/`Reload` and is read from the `Session`, never the daemon's own process environment. Enforced by a clippy `disallowed-methods` lint — a regression fails CI, not just review.
- **A daemon-wide last-pass redactor runs after each session's own**, built from every live session's secrets — the backstop if a bug ever let one session's secret reach another's output. It only removes text; it doesn't excuse a leak into the wrong session's tool *environment*, which the first two rules exist to prevent.
- **Secrets are wrapped in `Secret<T>`** ([src/secrets.rs](src/secrets.rs)) which zeroizes on drop and refuses to `Debug`-print. Never log a `Secret` value, never put one in a `format!`.
- **Pre-exec closures must be async-signal-safe and zero-alloc.** The closure passed to `Command::pre_exec()` in [src/exec.rs](src/exec.rs) — no allocation, no mutex, no `println!`, only raw libc calls. Errors from pre-exec abort the spawn.
- **Redaction is mandatory on the output path.** All bytes leaving the daemon to the client go through the Aho-Corasick redactor in [src/redact.rs](src/redact.rs), including base64 / URL-encoded / hex variants of secrets.

## Tests

- Most logic lives in `cargo test --lib` (479 tests). All hermetic.
- `tests/cli_integration.rs` spawns the real `airlock` binary and runs `daemon start/stop/status`. **These might fail in the Claude Code sandbox**
- Some `client.rs` tests read the real stdin of the process. Run `cargo test < /dev/null`. Otherwise these tests can hang on an inherited pipe that never closes.
- Most logic lives in `cargo test --lib`. All hermetic.
- `tests/cli_integration.rs` and other sandbox-spawning integration tests carry `#[cfg_attr(no_nested_sandbox, ignore = "needs a nestable sandbox")]` — a sandbox can't nest a sandbox. `build.rs` sets `cfg(no_nested_sandbox)` when the build itself runs inside an Airlock sandbox (`AIRLOCK_SANDBOX=1`) or with `AIRLOCK_NO_NESTED_SANDBOX=1` (the Nix flake sets it). A plain `cargo test` outside Airlock, including CI (Linux), runs them — CI is otherwise unchanged, no macOS job, no `--include-ignored`. If you're an agent and see these skipped, that's expected; ask the user to run `cargo test` from their own terminal to exercise them.
- Some `client.rs` tests read the real stdin of the process. Run `cargo test < /dev/null`, or they can hang on an inherited pipe that never closes.
- Toolchain (Rust, Landlock headers, etc.) comes from `nix develop`; run `nix develop -c cargo test` if `cargo` isn't already on `PATH`.

## Top-level docs — keep in sync with the code

These four docs each cover a different audience. When a change touches their subject matter, update them in the same commit — don't let them drift.

- **[SKILL.md](SKILL.md)** — agent-facing usage guide. Update when CLI surface (`airlock list/exec/daemon ...`), `airlock.toml` schema, exit codes, or redaction behavior change. Triggers: diffs in [src/main.rs](src/main.rs), [src/protocol.rs](src/protocol.rs), [src/config.rs](src/config.rs), user-visible parts of [src/redact.rs](src/redact.rs).
- **[SKILL.md](SKILL.md)** — agent-facing usage guide. Update when CLI surface (`airlock exec/tools/agent ...`), `airlock.toml` schema, exit codes, or redaction behavior change. Triggers: diffs in [src/main.rs](src/main.rs), [src/protocol.rs](src/protocol.rs), [src/config.rs](src/config.rs), user-visible parts of [src/redact.rs](src/redact.rs).
- **[README.md](README.md)** — human-facing intro, tagline, install, quick start. Update when the value proposition, supported platforms, install steps, or top-level usage examples change.
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the pieces fit (daemon/client split, fork sequence, sandbox model, redaction pipeline). Update when module boundaries, the daemonize flow, sandbox mechanism, or socket protocol shape change.
- **[SECURITY.md](SECURITY.md)** — threat model, trust boundaries, mitigations, known-bad patterns (curl exfiltration, base32/hex encoding, file writes, etc.). Update when a new threat is considered, a mitigation lands or is removed, or the trust boundary moves.
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ tokio = { version = "1", features = ["process", "io-util", "io-std", "macros", "
tokio-util = { version = "0.7", features = ["codec"] }
tokio-stream = "0.1"
libc = "0.2"
rustix = { version = "1", features = ["process", "fs"] }
rustix = { version = "1", features = ["event", "fs", "pipe", "process", "pty", "stdio", "termios"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
Expand All @@ -20,7 +20,7 @@ anyhow = "1"
thiserror = "2"
base64 = "0.22"
percent-encoding = "2"
zeroize = "1"
zeroize = { version = "1", features = ["serde"] }
leon = "3"
# Proxy-tool runtime. One rustls crypto provider (ring) is selected explicitly
# here and installed by name at CA construction, so nothing depends on
Expand All @@ -36,6 +36,8 @@ webpki-roots = "1"
time = "0.3"
getrandom = "0.3"
subtle = "2"
ring = "0.17"
similar = "2"

[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4"
Expand Down
Loading
Loading