From 7e4b98956a028e64a820d478aefe2b2791e9a36b Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Tue, 6 Oct 2026 23:27:47 +0300 Subject: [PATCH 001/119] feat(trust): add the config trust store, review rendering and prompts 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//, 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. --- Cargo.lock | 8 + Cargo.toml | 2 + src/lib.rs | 1 + src/trust.rs | 999 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1010 insertions(+) create mode 100644 src/trust.rs diff --git a/Cargo.lock b/Cargo.lock index ad62a3f..99e60b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,10 +29,12 @@ dependencies = [ "libc", "percent-encoding", "rcgen", + "ring", "rustix", "rustls", "serde", "serde_json", + "similar", "subtle", "tempfile", "thiserror", @@ -915,6 +917,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "smallvec" version = "1.16.1" diff --git a/Cargo.toml b/Cargo.toml index 086f587..d62d645 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/lib.rs b/src/lib.rs index 836cfa3..8eef957 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod refresh; pub mod run; pub mod sandbox; pub mod secrets; +pub mod trust; #[cfg(test)] pub(crate) mod test_support { diff --git a/src/trust.rs b/src/trust.rs new file mode 100644 index 0000000..07920a7 --- /dev/null +++ b/src/trust.rs @@ -0,0 +1,999 @@ +//! Config trust: approve project config by content, review changes, and +//! render the diffs and prompts a launcher or `airlock trust` shows the user. +//! +//! An agent runs as the user and can edit the project's `airlock.toml` or +//! `airlock.local.toml`. Those files can grant filesystem access and name +//! the commands that produce secrets, so Airlock never loads a changed file +//! without the user reviewing it first. This module holds the three pieces +//! that make that possible: +//! +//! - [`TrustStore`] — a copy of each approved file, keyed by project and +//! file name, so a later load can diff against exactly what was approved. +//! - [`escape_for_terminal`] and [`render_review`] — turn a file's bytes (or +//! a diff against the approved copy) into text that is safe to print: a +//! TOML file can hold control characters, bidi overrides and zero-width +//! characters that would otherwise make a diff read differently from what +//! it does. +//! - [`decide`] and [`prompt_yes_no`] — the `--yes` / `--expect-sha256` +//! decision for scripted approval, and the interactive prompt for a +//! terminal. +//! +//! The daemon never reads project config, so none of this runs there: it is +//! all launcher-side (`airlock run`, `session start`, `session reload`, +//! `airlock trust`). +//! +//! # Deviation from the implementation contract +//! +//! The contract's `trust.rs` sketch takes `anchors: &Anchors` and +//! `file: &LayerFile`, both defined in sibling modules (`anchors.rs`, +//! `layers.rs`) owned by other phase-1 agents and not present in this +//! worktree. [`TrustStore::open`] takes a plain `&Path` (the trust store +//! directory, i.e. `anchors.trust_store`) and [`TrustStore::state`] / +//! [`TrustStore::approve`] take `root: &Path` and `file_name: &str` plus the +//! bytes, instead of a `LayerFile`. The coordinator adapts call sites. + +use std::io::{self, BufRead, Read, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use similar::{ChangeTag, TextDiff}; +use thiserror::Error; + +// ─── Trust store ──────────────────────────────────────────────────────────── + +/// The first 16 hex characters of the SHA-256 of the canonical project root +/// path. Identifies a project's slot in the trust store; a project that +/// moves to a new path gets a new id and needs approval again. +pub fn project_id(root: &Path) -> String { + sha256_hex(root.as_os_str().as_bytes())[..16].to_string() +} + +/// SHA-256 of `bytes`, as lowercase hex. +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = ring::digest::digest(&ring::digest::SHA256, bytes); + digest.as_ref().iter().map(|b| format!("{b:02x}")).collect() +} + +/// Errors from reading or writing the trust store. +#[derive(Debug, Error)] +pub enum TrustError { + /// An I/O error on a specific path. + #[error("{path}: {source}")] + Io { path: PathBuf, source: io::Error }, + + /// A path inside the trust store is a symlink. The store is written only + /// by Airlock itself (temp file + rename), so a symlink in it means + /// something else wrote there; never follow it. + #[error("{path} is a symlink; refusing to follow it inside the trust store")] + Symlink { path: PathBuf }, + + /// A path that must be a directory (or must not exist yet) is something + /// else. + #[error("{path} exists and is not a directory")] + NotADirectory { path: PathBuf }, +} + +impl TrustError { + fn io(path: &Path, source: io::Error) -> Self { + TrustError::Io { + path: path.to_path_buf(), + source, + } + } +} + +/// The approval state of a file's current bytes against the trust store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Approval { + /// Current bytes match the approved copy. + Approved, + /// Current bytes differ from the approved copy, which is kept here so a + /// diff can be shown. + Changed { approved: Vec }, + /// No copy has ever been approved for this project and file name. + New, +} + +/// `$XDG_STATE_HOME/airlock/trust`, or wherever the caller resolved that +/// anchor to. Layout: `//root` (the canonical project root, +/// for debugging the store by hand) and `//` +/// (the approved copy of that file). +#[derive(Debug)] +pub struct TrustStore { + dir: PathBuf, +} + +impl TrustStore { + /// Opens the trust store rooted at `dir`, creating it (mode `0700`) if + /// it does not exist yet. + pub fn open(dir: &Path) -> Result { + ensure_dir(dir)?; + Ok(TrustStore { + dir: dir.to_path_buf(), + }) + } + + /// The trust store's root directory. + pub fn dir(&self) -> &Path { + &self.dir + } + + fn project_dir(&self, root: &Path) -> PathBuf { + self.dir.join(project_id(root)) + } + + /// Compares `bytes` (the file's current contents) against the approved + /// copy, if any, of the project rooted at `root`'s file named + /// `file_name`. + pub fn state( + &self, + root: &Path, + file_name: &str, + bytes: &[u8], + ) -> Result { + let copy_path = self.project_dir(root).join(file_name); + match read_store_file(©_path)? { + None => Ok(Approval::New), + Some(approved) => { + if approved == bytes { + Ok(Approval::Approved) + } else { + Ok(Approval::Changed { approved }) + } + } + } + } + + /// Records `bytes` as the approved copy of `file_name` for the project + /// rooted at `root`. Writes via a temp file and `rename` in the same + /// directory, so a reader never observes a partial file. + pub fn approve(&self, root: &Path, file_name: &str, bytes: &[u8]) -> Result<(), TrustError> { + let project_dir = self.project_dir(root); + ensure_dir(&project_dir)?; + write_store_file(&project_dir.join("root"), root.as_os_str().as_bytes())?; + write_store_file(&project_dir.join(file_name), bytes)?; + Ok(()) + } +} + +/// Creates `dir` with mode `0700` if it does not exist. Refuses a symlink or +/// a non-directory at that path. +fn ensure_dir(dir: &Path) -> Result<(), TrustError> { + match std::fs::symlink_metadata(dir) { + Ok(meta) if meta.file_type().is_symlink() => Err(TrustError::Symlink { + path: dir.to_path_buf(), + }), + Ok(meta) if !meta.is_dir() => Err(TrustError::NotADirectory { + path: dir.to_path_buf(), + }), + Ok(_) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + std::fs::create_dir_all(dir).map_err(|source| TrustError::io(dir, source))?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .map_err(|source| TrustError::io(dir, source))?; + Ok(()) + } + Err(source) => Err(TrustError::io(dir, source)), + } +} + +/// Reads a file from inside the trust store, refusing to follow a symlink. +/// `Ok(None)` means the file has never been approved. +fn read_store_file(path: &Path) -> Result>, TrustError> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => Err(TrustError::Symlink { + path: path.to_path_buf(), + }), + Ok(_) => { + let bytes = std::fs::read(path).map_err(|source| TrustError::io(path, source))?; + Ok(Some(bytes)) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(TrustError::io(path, source)), + } +} + +/// Writes `bytes` to `path` (mode `0600`) via a temp file and `rename` in +/// the same directory, so a concurrent reader sees either the old or the new +/// content, never a partial write. +fn write_store_file(path: &Path, bytes: &[u8]) -> Result<(), TrustError> { + let dir = path + .parent() + .expect("trust store file path always has a parent directory"); + + let mut attempt: u32 = 0; + let (tmp_path, mut file) = loop { + let tmp_path = dir.join(format!(".tmp-{}-{attempt}", std::process::id())); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp_path) + { + Ok(file) => break (tmp_path, file), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists && attempt < u32::MAX => { + attempt += 1; + } + Err(source) => return Err(TrustError::io(&tmp_path, source)), + } + }; + + let result = file + .write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|source| TrustError::io(&tmp_path, source)); + drop(file); + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp_path); + return Err(e); + } + + std::fs::rename(&tmp_path, path).map_err(|source| TrustError::io(path, source)) +} + +// ─── Escaping ─────────────────────────────────────────────────────────────── + +/// Escapes characters that could make printed text read differently from +/// what it does: control characters (other than `\n` and `\t`), ANSI +/// escapes, C1 controls, bidirectional overrides and isolates, and +/// zero-width characters. Each is shown as `\u{...}` (Rust-style, lowercase +/// hex, no padding). +/// +/// TOML strings and comments accept all of these, so a file is escaped +/// before it is ever printed for review: without this, an agent could write +/// a diff that the user reads one way while it parses another. +pub fn escape_for_terminal(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if needs_escape(c) { + out.push_str(&format!("\\u{{{:x}}}", c as u32)); + } else { + out.push(c); + } + } + out +} + +fn needs_escape(c: char) -> bool { + match c { + '\n' | '\t' => false, + // C0 controls, including ESC (0x1b), which starts an ANSI escape. + c if (c as u32) < 0x20 => true, + // DEL. + '\u{7f}' => true, + // C1 controls. + c if ('\u{80}'..='\u{9f}').contains(&c) => true, + // Zero-width space/joiners and the left-to-right/right-to-left marks. + '\u{200b}'..='\u{200f}' => true, + // Bidirectional embeddings and overrides. + '\u{202a}'..='\u{202e}' => true, + // Bidirectional isolates. + '\u{2066}'..='\u{2069}' => true, + // Line and paragraph separators. + '\u{2028}' | '\u{2029}' => true, + // Byte-order mark / zero-width no-break space. + '\u{feff}' => true, + // Arabic letter mark. + '\u{061c}' => true, + _ => false, + } +} + +// ─── Review rendering ─────────────────────────────────────────────────────── + +/// Renders the text a launcher or `airlock trust` shows before asking the +/// user to approve `path_display`: a unified diff against the approved copy +/// for [`Approval::Changed`], or the full (escaped) contents for +/// [`Approval::New`]. Returns an empty string for [`Approval::Approved`], +/// since nothing needs review. +/// +/// `annotate` is called with each line's raw (unescaped) text; when it +/// returns `Some(note)`, the displayed line gets ` # → ` +/// appended. This is for `from = "global"` lines, whose prompt shows what +/// the link resolves to right now — display only, not part of the approved +/// bytes. +pub fn render_review( + path_display: &str, + approval: &Approval, + current: &[u8], + annotate: &dyn Fn(&str) -> Option, +) -> String { + match approval { + Approval::Approved => String::new(), + Approval::New => { + let mut out = format!("{path_display} is not trusted yet. Contents:\n\n"); + let text = String::from_utf8_lossy(current); + for line in text.lines() { + out.push_str(" "); + out.push_str(&annotated_line(line, annotate)); + out.push('\n'); + } + out + } + Approval::Changed { approved } => { + let mut out = format!("{path_display} has changed since you last trusted it:\n\n"); + let old_text = String::from_utf8_lossy(approved); + let new_text = String::from_utf8_lossy(current); + out.push_str(&render_unified_diff( + &old_text, + &new_text, + path_display, + annotate, + )); + out + } + } +} + +fn annotated_line(line: &str, annotate: &dyn Fn(&str) -> Option) -> String { + let escaped = escape_for_terminal(line); + match annotate(line) { + Some(note) => format!("{escaped} # \u{2192} {note}"), + None => escaped, + } +} + +fn render_unified_diff( + old_text: &str, + new_text: &str, + path_display: &str, + annotate: &dyn Fn(&str) -> Option, +) -> String { + let diff = TextDiff::from_lines(old_text, new_text); + let mut out = String::new(); + out.push_str("--- trusted\n"); + out.push_str(&format!("+++ {path_display}\n")); + + for group in diff.grouped_ops(3) { + if group.is_empty() { + continue; + } + let header = similar::udiff::UnifiedHunkHeader::new(&group); + out.push_str(&format!("{header}\n")); + for op in &group { + for change in diff.iter_changes(op) { + let prefix = match change.tag() { + ChangeTag::Equal => ' ', + ChangeTag::Delete => '-', + ChangeTag::Insert => '+', + }; + let text = change.to_string_lossy(); + let line = text.strip_suffix('\n').unwrap_or(&text); + out.push(prefix); + out.push_str(&annotated_line(line, annotate)); + out.push('\n'); + } + } + } + + out +} + +// ─── Prompts ──────────────────────────────────────────────────────────────── + +/// Which command is asking, since the question text differs slightly +/// between the launcher (which also starts the agent on `y`) and +/// `airlock trust` (which only records the approval). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptKind { + /// `airlock run`, `session start`, `session reload`. + Launcher, + /// `airlock trust`. + TrustCommand, +} + +/// The exact question text for `kind`, given the file's approval state +/// (a never-approved file is asked about with "this file", a changed one +/// with "this version"). +pub fn prompt_question(kind: PromptKind, approval: &Approval) -> &'static str { + let is_new = matches!(approval, Approval::New); + match (kind, is_new) { + (PromptKind::Launcher, true) => "Trust this file and continue? [y/N]", + (PromptKind::Launcher, false) => "Trust this version and continue? [y/N]", + (PromptKind::TrustCommand, true) => "Trust this file? [y/N]", + (PromptKind::TrustCommand, false) => "Trust this version? [y/N]", + } +} + +/// Whether this process can prompt: both stdin and stderr are terminals. +/// `stderr`, because that is where Airlock's own notes and questions go; +/// `stdin`, because that is what a non-interactive caller (a script, a CI +/// job) typically redirects. +pub fn is_interactive() -> bool { + // SAFETY: isatty is always safe to call with a valid file descriptor; + // stdin and stderr are valid for the life of the process. + unsafe { libc::isatty(libc::STDIN_FILENO) != 0 && libc::isatty(libc::STDERR_FILENO) != 0 } +} + +/// Asks `question` on the controlling terminal (`/dev/tty`, not stdin: a +/// piped agent can still have a pty-backed controlling terminal, and the +/// question must reach the user there, not into the pipe) and reads one line +/// in response. Defaults to `false` (No) for anything but an explicit yes. +pub fn prompt_yes_no(question: &str) -> io::Result { + let mut tty = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty")?; + write!(tty, "{question} ")?; + tty.flush()?; + read_yes_no(&mut tty) +} + +/// The read side of [`prompt_yes_no`], taking any reader so tests can supply +/// one instead of a real terminal. +fn read_yes_no(reader: &mut R) -> io::Result { + let mut line = String::new(); + io::BufReader::new(reader).read_line(&mut line)?; + let answer = line.trim(); + Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes")) +} + +// ─── `--yes` / `--expect-sha256` decision ────────────────────────────────── + +/// One file in a batch awaiting approval, as the decision logic sees it: a +/// display path and the SHA-256 of its current bytes. +#[derive(Debug, Clone)] +pub struct UnapprovedFile { + pub path_display: String, + pub sha256_current: String, +} + +/// What to do about a batch of unapproved files, decided without asking +/// anything interactively. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// Approve every file in the batch. + Approve, + /// Prompt the user for each file one at a time (an interactive terminal + /// with neither `--yes` nor `--expect-sha256`). + Prompt, + /// Refuse the whole batch; nothing is approved. + Refuse { message: String }, +} + +/// The `--yes` / `--expect-sha256` decision for a batch of unapproved files. +/// +/// - `yes` approves every file, unconditionally. +/// - Otherwise, a non-empty `expect_sha256` approves the batch only if every +/// file's current hash is in that list, and otherwise refuses, naming the +/// first file that does not match and its actual hash. +/// - Otherwise, an interactive caller should prompt file by file +/// ([`Decision::Prompt`]); a non-interactive one is refused and pointed at +/// `airlock trust`. +pub fn decide( + files: &[UnapprovedFile], + interactive: bool, + yes: bool, + expect_sha256: &[String], +) -> Decision { + if yes { + return Decision::Approve; + } + + if !expect_sha256.is_empty() { + for file in files { + let matches = expect_sha256 + .iter() + .any(|expected| expected.eq_ignore_ascii_case(&file.sha256_current)); + if !matches { + return Decision::Refuse { + message: format!( + "{} does not match any --expect-sha256 hash (current: {})", + file.path_display, file.sha256_current + ), + }; + } + } + return Decision::Approve; + } + + if interactive { + Decision::Prompt + } else { + Decision::Refuse { + message: "run `airlock trust` in a terminal to approve it".to_string(), + } + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + // --- TrustStore --- + + #[test] + fn new_file_has_no_approval() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + let state = store.state(root, "airlock.toml", b"hello").unwrap(); + assert_eq!(state, Approval::New); + } + + #[test] + fn approve_then_state_round_trips() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + + store.approve(root, "airlock.toml", b"version one").unwrap(); + let state = store.state(root, "airlock.toml", b"version one").unwrap(); + assert_eq!(state, Approval::Approved); + } + + #[test] + fn changed_bytes_are_reported_with_the_approved_copy() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + + store.approve(root, "airlock.toml", b"version one").unwrap(); + let state = store.state(root, "airlock.toml", b"version two").unwrap(); + assert_eq!( + state, + Approval::Changed { + approved: b"version one".to_vec() + } + ); + } + + #[test] + fn different_file_names_are_independent() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + + store.approve(root, "airlock.toml", b"repo").unwrap(); + // The local file for the same project has never been approved. + let state = store.state(root, "airlock.local.toml", b"local").unwrap(); + assert_eq!(state, Approval::New); + } + + #[test] + fn different_roots_are_independent() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + + store + .approve( + Path::new("/home/me/src/app"), + "airlock.toml", + b"app's config", + ) + .unwrap(); + let state = store + .state( + Path::new("/home/me/src/other"), + "airlock.toml", + b"app's config", + ) + .unwrap(); + assert_eq!(state, Approval::New); + } + + #[test] + fn store_dir_is_mode_0700() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let store_dir = dir.path().join("trust"); + TrustStore::open(&store_dir).unwrap(); + + let mode = std::fs::metadata(&store_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + } + + #[test] + fn approved_copy_is_mode_0600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + store.approve(root, "airlock.toml", b"hello").unwrap(); + + let copy_path = dir.path().join(project_id(root)).join("airlock.toml"); + let mode = std::fs::metadata(©_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + + let root_path = dir.path().join(project_id(root)).join("root"); + let root_mode = std::fs::metadata(&root_path).unwrap().permissions().mode() & 0o777; + assert_eq!(root_mode, 0o600); + assert_eq!( + std::fs::read(&root_path).unwrap(), + root.as_os_str().as_bytes() + ); + } + + #[test] + fn project_dir_itself_mode_0700() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + store.approve(root, "airlock.toml", b"hello").unwrap(); + + let project_dir = dir.path().join(project_id(root)); + let mode = std::fs::metadata(&project_dir) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + } + + #[test] + fn refuses_a_symlinked_store_dir() { + let dir = tempdir().unwrap(); + let real = dir.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = dir.path().join("link"); + symlink(&real, &link).unwrap(); + + let err = TrustStore::open(&link).unwrap_err(); + assert!(matches!(err, TrustError::Symlink { .. })); + } + + #[test] + fn refuses_a_symlinked_approved_copy() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + + // Plant a symlink where an approved copy would live, bypassing the + // store's own writer. + let project_dir = dir.path().join(project_id(root)); + std::fs::create_dir_all(&project_dir).unwrap(); + let outside = dir.path().join("outside.toml"); + std::fs::write(&outside, b"not actually approved").unwrap(); + symlink(&outside, project_dir.join("airlock.toml")).unwrap(); + + let err = store.state(root, "airlock.toml", b"whatever").unwrap_err(); + assert!(matches!(err, TrustError::Symlink { .. })); + } + + #[test] + fn refuses_a_symlinked_project_dir() { + let dir = tempdir().unwrap(); + let store = TrustStore::open(dir.path()).unwrap(); + let root = Path::new("/home/me/src/app"); + + let real = dir.path().join("real-project-dir"); + std::fs::create_dir(&real).unwrap(); + symlink(&real, dir.path().join(project_id(root))).unwrap(); + + let err = store.approve(root, "airlock.toml", b"hello").unwrap_err(); + assert!(matches!(err, TrustError::Symlink { .. })); + } + + #[test] + fn project_id_is_stable_and_distinguishes_roots() { + let a = project_id(Path::new("/home/me/src/app")); + let a_again = project_id(Path::new("/home/me/src/app")); + let b = project_id(Path::new("/home/me/src/other")); + + assert_eq!(a, a_again); + assert_ne!(a, b); + assert_eq!(a.len(), 16); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn sha256_hex_is_lowercase_and_64_chars() { + let hash = sha256_hex(b""); + assert_eq!(hash.len(), 64); + assert!( + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + // Known SHA-256 of the empty string. + assert_eq!( + hash, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + // --- Escaping --- + + #[test] + fn escape_preserves_newline_and_tab() { + assert_eq!(escape_for_terminal("a\nb\tc"), "a\nb\tc"); + } + + #[test] + fn escape_c0_control_and_esc() { + assert_eq!(escape_for_terminal("\u{01}"), "\\u{1}"); + assert_eq!(escape_for_terminal("\u{1b}"), "\\u{1b}"); // ESC: starts ANSI escapes + assert_eq!(escape_for_terminal("\u{1b}[31m"), "\\u{1b}[31m"); + } + + #[test] + fn escape_del_and_c1_controls() { + assert_eq!(escape_for_terminal("\u{7f}"), "\\u{7f}"); + assert_eq!(escape_for_terminal("\u{85}"), "\\u{85}"); // NEL, a C1 control + assert_eq!(escape_for_terminal("\u{9f}"), "\\u{9f}"); + } + + #[test] + fn escape_bidi_overrides_and_isolates() { + assert_eq!(escape_for_terminal("\u{202e}"), "\\u{202e}"); // RLO + assert_eq!(escape_for_terminal("\u{202a}"), "\\u{202a}"); // LRE + assert_eq!(escape_for_terminal("\u{2066}"), "\\u{2066}"); // LRI + assert_eq!(escape_for_terminal("\u{2069}"), "\\u{2069}"); // PDI + } + + #[test] + fn escape_zero_width_and_marks() { + assert_eq!(escape_for_terminal("\u{200b}"), "\\u{200b}"); // ZWSP + assert_eq!(escape_for_terminal("\u{200e}"), "\\u{200e}"); // LRM + assert_eq!(escape_for_terminal("\u{feff}"), "\\u{feff}"); // BOM + assert_eq!(escape_for_terminal("\u{061c}"), "\\u{61c}"); // ALM + } + + #[test] + fn escape_line_and_paragraph_separators() { + assert_eq!(escape_for_terminal("\u{2028}"), "\\u{2028}"); + assert_eq!(escape_for_terminal("\u{2029}"), "\\u{2029}"); + } + + #[test] + fn escape_leaves_ordinary_text_alone() { + let s = "description = \"GitHub CLI\""; + assert_eq!(escape_for_terminal(s), s); + } + + #[test] + fn escape_matches_the_ux_doc_hidden_edit_example() { + // docs/airlock-v2-ux.md, "The agent changes the config": a bidi + // override hidden in a string must come out visibly. + let s = "description = \"GitHub CLI\u{202e}\""; + assert_eq!( + escape_for_terminal(s), + "description = \"GitHub CLI\\u{202e}\"" + ); + } + + // --- Review rendering --- + + fn no_annotation(_: &str) -> Option { + None + } + + #[test] + fn render_new_file_shows_indented_contents() { + let content = + b"[secrets.GH_TOKEN]\nsource = \"command\"\ncommand = [\"gh\", \"auth\", \"token\"]\n"; + let out = render_review( + "~/src/app/airlock.toml", + &Approval::New, + content, + &no_annotation, + ); + assert_eq!( + out, + "~/src/app/airlock.toml is not trusted yet. Contents:\n\n\ + \x20 [secrets.GH_TOKEN]\n\ + \x20 source = \"command\"\n\ + \x20 command = [\"gh\", \"auth\", \"token\"]\n" + ); + } + + #[test] + fn render_approved_is_empty() { + let out = render_review( + "~/src/app/airlock.toml", + &Approval::Approved, + b"anything", + &no_annotation, + ); + assert_eq!(out, ""); + } + + #[test] + fn render_changed_file_is_a_unified_diff() { + // docs/airlock-v2-design.md, "A changed file at session start". + let old = "secrets block\n\nGH_TOKEN = { secret = \"GH_TOKEN\" }\n\n[filesystem]\nread = [\"/opt/homebrew/share\"]\n"; + let new = "secrets block\n\nGH_TOKEN = { secret = \"GH_TOKEN\" }\n\n[filesystem]\nread = [\"/opt/homebrew/share\"]\nwrite = [\"~/.ssh\"]\n"; + + let approval = Approval::Changed { + approved: old.as_bytes().to_vec(), + }; + let out = render_review( + "~/src/app/airlock.toml", + &approval, + new.as_bytes(), + &no_annotation, + ); + + assert!( + out.starts_with("~/src/app/airlock.toml has changed since you last trusted it:\n\n") + ); + assert!(out.contains("--- trusted\n")); + assert!(out.contains("+++ ~/src/app/airlock.toml\n")); + assert!(out.contains("@@ ")); + assert!(out.contains("+write = [\"~/.ssh\"]\n")); + // Unchanged lines carry the unified-diff context prefix, not the + // 4-space indent used for a never-approved file's full contents. + assert!(out.contains(" [filesystem]\n")); + } + + #[test] + fn render_changed_file_matches_full_expected_diff() { + let old = (1..=9).map(|n| format!("line{n}\n")).collect::() + + "GH_TOKEN = { secret = \"GH_TOKEN\" }\n\n[filesystem]\nread = [\"/opt/homebrew/share\"]\n"; + let new = (1..=9).map(|n| format!("line{n}\n")).collect::() + + "GH_TOKEN = { secret = \"GH_TOKEN\" }\n\n[filesystem]\nread = [\"/opt/homebrew/share\"]\nwrite = [\"~/.ssh\"]\n"; + + let approval = Approval::Changed { + approved: old.into_bytes(), + }; + let out = render_review( + "~/src/app/airlock.toml", + &approval, + new.as_bytes(), + &no_annotation, + ); + + let expected = "~/src/app/airlock.toml has changed since you last trusted it:\n\n\ +--- trusted\n\ ++++ ~/src/app/airlock.toml\n\ +@@ -11,3 +11,4 @@\n\ +\x20\n\ +\x20[filesystem]\n\ +\x20read = [\"/opt/homebrew/share\"]\n\ ++write = [\"~/.ssh\"]\n"; + assert_eq!(out, expected); + } + + #[test] + fn render_annotates_matching_lines_only() { + let content = b"[secrets.GH_TOKEN]\nfrom = \"global\"\n"; + let annotate = |line: &str| { + if line == "from = \"global\"" { + Some("global: command op read op://Private/GitHub/token".to_string()) + } else { + None + } + }; + let out = render_review( + "~/src/app/airlock.local.toml", + &Approval::New, + content, + &annotate, + ); + assert!(out.contains( + " from = \"global\" # \u{2192} global: command op read op://Private/GitHub/token\n" + )); + assert!(out.contains(" [secrets.GH_TOKEN]\n")); + } + + #[test] + fn render_escapes_hostile_bytes_before_annotating() { + let content = "name = \"x\u{202e}\"\n".as_bytes().to_vec(); + let out = render_review("f.toml", &Approval::New, &content, &no_annotation); + assert!(out.contains("\\u{202e}")); + assert!(!out.contains('\u{202e}')); + } + + // --- Prompts --- + + #[test] + fn prompt_question_text_matches_the_ux_doc() { + assert_eq!( + prompt_question(PromptKind::Launcher, &Approval::New), + "Trust this file and continue? [y/N]" + ); + assert_eq!( + prompt_question( + PromptKind::Launcher, + &Approval::Changed { approved: vec![] } + ), + "Trust this version and continue? [y/N]" + ); + assert_eq!( + prompt_question(PromptKind::TrustCommand, &Approval::New), + "Trust this file? [y/N]" + ); + assert_eq!( + prompt_question( + PromptKind::TrustCommand, + &Approval::Changed { approved: vec![] } + ), + "Trust this version? [y/N]" + ); + } + + #[test] + fn read_yes_no_accepts_y_and_yes_case_insensitively() { + for input in ["y\n", "Y\n", "yes\n", "YES\n", "y\r\n"] { + let mut cursor = std::io::Cursor::new(input.as_bytes()); + assert!( + read_yes_no(&mut cursor).unwrap(), + "input {input:?} should be yes" + ); + } + } + + #[test] + fn read_yes_no_defaults_to_no() { + for input in ["n\n", "no\n", "\n", "", "anything else\n"] { + let mut cursor = std::io::Cursor::new(input.as_bytes()); + assert!( + !read_yes_no(&mut cursor).unwrap(), + "input {input:?} should be no" + ); + } + } + + // --- Decision logic --- + + fn file(path: &str, hash: &str) -> UnapprovedFile { + UnapprovedFile { + path_display: path.to_string(), + sha256_current: hash.to_string(), + } + } + + #[test] + fn decide_yes_approves_regardless_of_everything_else() { + let files = vec![file("a.toml", "aaaa")]; + assert_eq!(decide(&files, false, true, &[]), Decision::Approve); + assert_eq!( + decide(&files, true, true, &["zzzz".to_string()]), + Decision::Approve + ); + } + + #[test] + fn decide_non_interactive_without_flags_refuses() { + let files = vec![file("a.toml", "aaaa")]; + let decision = decide(&files, false, false, &[]); + assert_eq!( + decision, + Decision::Refuse { + message: "run `airlock trust` in a terminal to approve it".to_string() + } + ); + } + + #[test] + fn decide_interactive_without_flags_prompts() { + let files = vec![file("a.toml", "aaaa")]; + assert_eq!(decide(&files, true, false, &[]), Decision::Prompt); + } + + #[test] + fn decide_expect_sha256_approves_when_every_file_matches() { + let files = vec![file("a.toml", "aaaa"), file("b.toml", "bbbb")]; + let expected = vec!["aaaa".to_string(), "bbbb".to_string(), "cccc".to_string()]; + assert_eq!(decide(&files, false, false, &expected), Decision::Approve); + } + + #[test] + fn decide_expect_sha256_refuses_naming_the_mismatch() { + let files = vec![file("a.toml", "aaaa"), file("b.toml", "bbbb")]; + let expected = vec!["aaaa".to_string()]; + let decision = decide(&files, true, false, &expected); + match decision { + Decision::Refuse { message } => { + assert!(message.contains("b.toml")); + assert!(message.contains("bbbb")); + } + other => panic!("expected Refuse, got {other:?}"), + } + } + + #[test] + fn decide_expect_sha256_is_case_insensitive() { + let files = vec![file("a.toml", "aaaa")]; + let expected = vec!["AAAA".to_string()]; + assert_eq!(decide(&files, false, false, &expected), Decision::Approve); + } +} From cf835037de90d593d5398b895a338e4d467d1b84 Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Tue, 6 Oct 2026 23:28:37 +0300 Subject: [PATCH 002/119] feat(runtime): add runtime_dir, anchors and process_tree modules for v2 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/ 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//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 ": 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. --- src/anchors.rs | 820 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 + src/process_tree.rs | 240 +++++++++++++ src/runtime_dir.rs | 504 +++++++++++++++++++++++++++ 4 files changed, 1567 insertions(+) create mode 100644 src/anchors.rs create mode 100644 src/process_tree.rs create mode 100644 src/runtime_dir.rs diff --git a/src/anchors.rs b/src/anchors.rs new file mode 100644 index 0000000..4ae4025 --- /dev/null +++ b/src/anchors.rs @@ -0,0 +1,820 @@ +//! The three locations that decide what the daemon trusts and how clients +//! reach it: the trust store, the global config, and the runtime base. +//! +//! The XDG variables that locate the trust store and global config are +//! honored, which makes the environment an attack path: a committed +//! `mise.toml` or `.envrc` can redirect `XDG_STATE_HOME` once the user `cd`s +//! in. Validation here closes that off by requiring every anchor to sit +//! outside the project root and outside every sandbox write grant, after +//! canonicalizing the longest prefix that actually exists on disk. +//! +//! Callers pass the environment in as a closure rather than this module +//! reading it directly, so daemon-side code — which must never read the +//! process environment — cannot accidentally depend on this module to do +//! so, and so tests can exercise it without mutating process state. + +use std::os::unix::fs::{DirBuilderExt, MetadataExt}; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +use crate::runtime_dir::RuntimeDir; + +/// The trust store, global config and runtime base, resolved from the +/// environment (or its defaults) and ready for validation. +#[derive(Debug, Clone)] +pub struct Anchors { + /// `$XDG_STATE_HOME/airlock/trust`, default `~/.local/state/airlock/trust`. + pub trust_store: PathBuf, + /// The XDG variable and value that produced `trust_store`, e.g. + /// `"XDG_STATE_HOME=/x"`, when it was not the default. + pub trust_store_source: Option, + /// `$XDG_CONFIG_HOME/airlock/airlock.toml`, default + /// `~/.config/airlock/airlock.toml` (on macOS too). + pub global_config: PathBuf, + /// The XDG variable and value that produced `global_config`'s + /// directory, when it was not the default. + pub global_config_source: Option, + /// `$XDG_CACHE_HOME/airlock`, default `~/.cache/airlock`. + pub tool_state_base: PathBuf, + /// The daemon's runtime directory base. + pub runtime_base: PathBuf, +} + +impl Anchors { + /// The three anchors that must never be forgeable or redirected by a + /// sandboxed process, each labeled for error messages. `tool_state_base` + /// is not an anchor in this sense — it is a fresh per-tool scratch + /// directory with no trust record of its own. + fn named(&self) -> [(&Path, &'static str, Option<&str>); 3] { + [ + ( + self.trust_store.as_path(), + "trust store", + self.trust_store_source.as_deref(), + ), + ( + self.global_config.as_path(), + "global config", + self.global_config_source.as_deref(), + ), + (self.runtime_base.as_path(), "runtime dir", None), + ] + } +} + +/// Resolves the anchors from the environment. `env` is a lookup function +/// rather than `std::env::var` directly, so this is testable and so +/// daemon-side code never reads the process environment through it. +pub fn resolve(env: &dyn Fn(&str) -> Option, home: &Path, runtime: &RuntimeDir) -> Anchors { + let (state_dir, trust_store_source) = xdg_dir(env, home, "XDG_STATE_HOME", ".local/state"); + let (config_dir, global_config_source) = xdg_dir(env, home, "XDG_CONFIG_HOME", ".config"); + let (cache_dir, _) = xdg_dir(env, home, "XDG_CACHE_HOME", ".cache"); + + Anchors { + trust_store: state_dir.join("airlock").join("trust"), + trust_store_source, + global_config: config_dir.join("airlock").join("airlock.toml"), + global_config_source, + tool_state_base: cache_dir.join("airlock"), + runtime_base: runtime.base().to_path_buf(), + } +} + +/// Resolves one XDG base directory variable, falling back to +/// `home/default_rel` when it is unset or empty. Returns the variable's +/// `NAME=value` form for error messages when it was actually used. +fn xdg_dir( + env: &dyn Fn(&str) -> Option, + home: &Path, + var: &str, + default_rel: &str, +) -> (PathBuf, Option) { + match env(var) { + Some(value) if !value.is_empty() => (PathBuf::from(&value), Some(format!("{var}={value}"))), + _ => (home.join(default_rel), None), + } +} + +/// Validates the three anchors: ownership and mode, outside the project +/// root, and outside every sandbox write grant. Creates the trust store +/// directory (mode 0700) if it does not exist yet. +pub fn validate( + anchors: &Anchors, + project_root: Option<&Path>, + write_grants: &[PathBuf], +) -> Result<(), AnchorError> { + create_trust_store_dir(&anchors.trust_store)?; + + check_strict_dir( + &anchors.trust_store, + "trust store", + anchors.trust_store_source.as_deref(), + )?; + check_strict_dir(&anchors.runtime_base, "runtime dir", None)?; + check_global_config( + &anchors.global_config, + anchors.global_config_source.as_deref(), + )?; + + for (path, label, source) in anchors.named() { + check_outside_root(path, label, source, project_root)?; + check_not_granted(path, label, write_grants)?; + } + + Ok(()) +} + +/// Checks a config's write grants against the anchors, in the direction a +/// config error takes: a grant that covers an anchor is refused at load +/// time, before the sandbox that would use it ever starts. +pub fn check_grants_against_anchors( + anchors: &Anchors, + grants: &[Grant<'_>], +) -> Result<(), AnchorError> { + for grant in grants { + for (anchor_path, anchor_label, _source) in anchors.named() { + if !overlaps(grant.path, anchor_path) { + continue; + } + if anchor_label == "runtime dir" + && cfg!(target_os = "macos") + && is_strict_ancestor(grant.path, anchor_path) + { + // The macOS $TMPDIR exception: the Seatbelt baseline grants + // read-write to all of $TMPDIR, and the runtime base + // normally sits inside it. The daemon carves the base back + // out with a trailing deny rule (see sandbox.rs), so a + // grant that merely contains the base, without reaching + // inside it, is not a real path to the anchor. + continue; + } + return Err(AnchorError::Covered { + file: grant.file.to_string(), + path: grant.path.to_path_buf(), + anchor: anchor_label.to_string(), + }); + } + } + Ok(()) +} + +/// One sandbox write grant from a loaded config, with enough context to +/// name where it came from in an error message. +pub struct Grant<'a> { + /// The file that declared this grant, e.g. `"airlock.toml"`. + pub file: &'a str, + pub path: &'a Path, +} + +fn check_strict_dir( + path: &Path, + label: &'static str, + source: Option<&str>, +) -> Result<(), AnchorError> { + let meta = std::fs::symlink_metadata(path).map_err(|e| AnchorError::Missing { + label: label.to_string(), + path: path.to_path_buf(), + source: e, + })?; + + if meta.file_type().is_symlink() { + return Err(AnchorError::IsSymlink { + label: label.to_string(), + path: path.to_path_buf(), + }); + } + if !meta.is_dir() { + return Err(AnchorError::NotADirectory { + label: label.to_string(), + path: path.to_path_buf(), + }); + } + + check_owned_by_euid(path, &meta, label, source)?; + + let mode = meta.mode() & 0o777; + if mode & 0o077 != 0 { + return Err(AnchorError::BadMode { + label: label.to_string(), + path: path.to_path_buf(), + mode, + xdg_source: source.map(str::to_string), + }); + } + + Ok(()) +} + +/// Checks the global config file and its directory, only when they exist — +/// an absent global config is not an error. +fn check_global_config(path: &Path, source: Option<&str>) -> Result<(), AnchorError> { + if let Ok(meta) = std::fs::symlink_metadata(path) { + check_owned_by_euid(path, &meta, "global config", source)?; + check_not_group_or_world_writable(path, &meta, "global config", source)?; + } + if let Some(dir) = path.parent() + && let Ok(meta) = std::fs::symlink_metadata(dir) + { + check_owned_by_euid(dir, &meta, "global config directory", source)?; + check_not_group_or_world_writable(dir, &meta, "global config directory", source)?; + } + Ok(()) +} + +fn check_owned_by_euid( + path: &Path, + meta: &std::fs::Metadata, + label: &str, + source: Option<&str>, +) -> Result<(), AnchorError> { + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + return Err(AnchorError::WrongOwner { + label: label.to_string(), + path: path.to_path_buf(), + owner: meta.uid(), + euid, + xdg_source: source.map(str::to_string), + }); + } + Ok(()) +} + +fn check_not_group_or_world_writable( + path: &Path, + meta: &std::fs::Metadata, + label: &str, + source: Option<&str>, +) -> Result<(), AnchorError> { + let mode = meta.mode() & 0o777; + if mode & 0o022 != 0 { + return Err(AnchorError::GroupOrWorldWritable { + label: label.to_string(), + path: path.to_path_buf(), + mode, + xdg_source: source.map(str::to_string), + }); + } + Ok(()) +} + +fn check_outside_root( + path: &Path, + label: &'static str, + source: Option<&str>, + project_root: Option<&Path>, +) -> Result<(), AnchorError> { + let Some(root) = project_root else { + return Ok(()); + }; + if is_inside(path, root) { + return Err(AnchorError::InsideRoot { + label: label.to_string(), + path: path.to_path_buf(), + root: root.to_path_buf(), + xdg_source: source.map(str::to_string), + }); + } + Ok(()) +} + +fn check_not_granted( + path: &Path, + label: &'static str, + write_grants: &[PathBuf], +) -> Result<(), AnchorError> { + for grant in write_grants { + if !overlaps(path, grant) { + continue; + } + if label == "runtime dir" && cfg!(target_os = "macos") && is_strict_ancestor(grant, path) { + continue; + } + return Err(AnchorError::Covered { + file: "sandbox configuration".to_string(), + path: grant.clone(), + anchor: label.to_string(), + }); + } + Ok(()) +} + +/// Creates the trust store directory if it does not exist, with mode 0700 +/// on the final component. Intermediate directories (e.g. a missing +/// `$XDG_STATE_HOME/airlock`) are created with the default mode; only the +/// trust store's own directory needs to be unreadable by anyone else. +pub fn create_trust_store_dir(dir: &Path) -> Result<(), AnchorError> { + if std::fs::symlink_metadata(dir).is_ok() { + return Ok(()); + } + if let Some(parent) = dir.parent() { + std::fs::create_dir_all(parent).map_err(|source| AnchorError::Create { + path: parent.to_path_buf(), + source, + })?; + } + std::fs::DirBuilder::new() + .mode(0o700) + .create(dir) + .map_err(|source| AnchorError::Create { + path: dir.to_path_buf(), + source, + }) +} + +/// True when `a` and `b` overlap: one is equal to, or inside, the other. +/// Canonicalizes the longest prefix of each that actually exists on disk, +/// so a not-yet-created path still compares correctly against a real one. +pub fn overlaps(a: &Path, b: &Path) -> bool { + let ca = canonicalize_longest_prefix(a); + let cb = canonicalize_longest_prefix(b); + ca.starts_with(&cb) || cb.starts_with(&ca) +} + +/// True when `path` is equal to, or inside, `dir`. +pub fn is_inside(path: &Path, dir: &Path) -> bool { + canonicalize_longest_prefix(path).starts_with(canonicalize_longest_prefix(dir)) +} + +/// True when `ancestor` properly contains `path` (not equal to it). +fn is_strict_ancestor(ancestor: &Path, path: &Path) -> bool { + let ca = canonicalize_longest_prefix(ancestor); + let cp = canonicalize_longest_prefix(path); + cp != ca && cp.starts_with(&ca) +} + +/// Canonicalizes the longest prefix of `path` that exists on disk (resolving +/// symlinks in it), then re-appends the remaining, not-yet-existing +/// components unchanged. +fn canonicalize_longest_prefix(path: &Path) -> PathBuf { + let mut suffix: Vec = Vec::new(); + let mut current = path.to_path_buf(); + + loop { + if let Ok(canonical) = std::fs::canonicalize(¤t) { + let mut result = canonical; + for component in suffix.iter().rev() { + result.push(component); + } + return result; + } + + match ( + current.file_name().map(|n| n.to_os_string()), + current.parent(), + ) { + (Some(name), Some(parent)) if !parent.as_os_str().is_empty() => { + suffix.push(name); + current = parent.to_path_buf(); + } + _ => { + // Nothing on the path exists, not even its root. Return the + // original, unresolved — there is nothing left to canonicalize. + return path.to_path_buf(); + } + } + } +} + +/// Errors validating or creating the anchors. +#[derive(Debug, Error)] +pub enum AnchorError { + #[error("could not create {}: {source}", path.display())] + Create { + path: PathBuf, + source: std::io::Error, + }, + + #[error("{label} {} does not exist: {source}", path.display())] + Missing { + label: String, + path: PathBuf, + source: std::io::Error, + }, + + #[error("{label} {} is a symlink", path.display())] + IsSymlink { label: String, path: PathBuf }, + + #[error("{label} {} is not a directory", path.display())] + NotADirectory { label: String, path: PathBuf }, + + #[error( + "{label} {} is owned by uid {owner}, not you ({euid}){}", + path.display(), + xdg_source.as_deref().map(|s| format!(" (from {s})")).unwrap_or_default() + )] + WrongOwner { + label: String, + path: PathBuf, + owner: u32, + euid: u32, + xdg_source: Option, + }, + + #[error( + "{label} {} has mode {mode:03o}, expected 0700{}", + path.display(), + xdg_source.as_deref().map(|s| format!(" (from {s})")).unwrap_or_default() + )] + BadMode { + label: String, + path: PathBuf, + mode: u32, + xdg_source: Option, + }, + + #[error( + "{label} {} has mode {mode:03o}, which is writable by group or other{}", + path.display(), + xdg_source.as_deref().map(|s| format!(" (from {s})")).unwrap_or_default() + )] + GroupOrWorldWritable { + label: String, + path: PathBuf, + mode: u32, + xdg_source: Option, + }, + + #[error( + "{label} {} is inside the project root {}{}; refusing to use it", + path.display(), + root.display(), + xdg_source.as_deref().map(|s| format!(" (from {s})")).unwrap_or_default() + )] + InsideRoot { + label: String, + path: PathBuf, + root: PathBuf, + xdg_source: Option, + }, + + #[error( + "{file}: filesystem.write \"{}\" covers {anchor}; Airlock's own files cannot be writable from a sandbox", + path.display() + )] + Covered { + file: String, + path: PathBuf, + anchor: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + fn tempdir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn fixed_runtime(base: PathBuf) -> RuntimeDir { + RuntimeDir::at(base) + } + + fn no_env(_: &str) -> Option { + None + } + + // ─── resolve ──────────────────────────────────────────────────────── + + #[test] + fn resolve_defaults_under_home_when_env_unset() { + let home = PathBuf::from("/home/u"); + let runtime = fixed_runtime(PathBuf::from("/run/u")); + let anchors = resolve(&no_env, &home, &runtime); + + assert_eq!( + anchors.trust_store, + PathBuf::from("/home/u/.local/state/airlock/trust") + ); + assert_eq!(anchors.trust_store_source, None); + assert_eq!( + anchors.global_config, + PathBuf::from("/home/u/.config/airlock/airlock.toml") + ); + assert_eq!(anchors.global_config_source, None); + assert_eq!( + anchors.tool_state_base, + PathBuf::from("/home/u/.cache/airlock") + ); + assert_eq!(anchors.runtime_base, PathBuf::from("/run/u")); + } + + #[test] + fn resolve_honors_xdg_vars_and_records_source() { + let home = PathBuf::from("/home/u"); + let runtime = fixed_runtime(PathBuf::from("/run/u")); + let env = |name: &str| match name { + "XDG_STATE_HOME" => Some("/custom/state".to_string()), + "XDG_CONFIG_HOME" => Some("/custom/config".to_string()), + "XDG_CACHE_HOME" => Some("/custom/cache".to_string()), + _ => None, + }; + let anchors = resolve(&env, &home, &runtime); + + assert_eq!( + anchors.trust_store, + PathBuf::from("/custom/state/airlock/trust") + ); + assert_eq!( + anchors.trust_store_source.as_deref(), + Some("XDG_STATE_HOME=/custom/state") + ); + assert_eq!( + anchors.global_config, + PathBuf::from("/custom/config/airlock/airlock.toml") + ); + assert_eq!( + anchors.global_config_source.as_deref(), + Some("XDG_CONFIG_HOME=/custom/config") + ); + assert_eq!( + anchors.tool_state_base, + PathBuf::from("/custom/cache/airlock") + ); + } + + #[test] + fn resolve_treats_empty_env_var_as_unset() { + let home = PathBuf::from("/home/u"); + let runtime = fixed_runtime(PathBuf::from("/run/u")); + let env = |name: &str| { + if name == "XDG_STATE_HOME" { + Some(String::new()) + } else { + None + } + }; + let anchors = resolve(&env, &home, &runtime); + + assert_eq!( + anchors.trust_store, + PathBuf::from("/home/u/.local/state/airlock/trust") + ); + assert_eq!(anchors.trust_store_source, None); + } + + // ─── overlaps / is_inside ─────────────────────────────────────────── + + #[test] + fn overlaps_true_for_equal_paths() { + let dir = tempdir(); + assert!(overlaps(dir.path(), dir.path())); + } + + #[test] + fn overlaps_true_when_one_is_inside_the_other() { + let dir = tempdir(); + let child = dir.path().join("a").join("b"); + std::fs::create_dir_all(&child).unwrap(); + assert!(overlaps(dir.path(), &child)); + assert!(overlaps(&child, dir.path())); + } + + #[test] + fn overlaps_false_for_disjoint_siblings() { + let dir = tempdir(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + assert!(!overlaps(&a, &b)); + } + + #[test] + fn overlaps_handles_not_yet_created_descendant() { + let dir = tempdir(); + let not_yet = dir.path().join("exists").join("not-yet-created"); + std::fs::create_dir_all(dir.path().join("exists")).unwrap(); + assert!(overlaps(dir.path(), ¬_yet)); + } + + #[test] + fn is_inside_resolves_symlinked_prefix() { + let dir = tempdir(); + let real = dir.path().join("real"); + std::fs::create_dir_all(real.join("sub")).unwrap(); + let link = dir.path().join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // Through the symlink, "link/sub" and "real/sub" are the same place. + assert!(is_inside(&link.join("sub"), &real)); + } + + // ─── create_trust_store_dir ───────────────────────────────────────── + + #[test] + fn create_trust_store_dir_sets_mode_0700_on_leaf_only() { + let dir = tempdir(); + let trust = dir.path().join("state").join("airlock").join("trust"); + + create_trust_store_dir(&trust).unwrap(); + + let leaf_mode = std::fs::metadata(&trust).unwrap().mode() & 0o777; + assert_eq!(leaf_mode, 0o700); + } + + #[test] + fn create_trust_store_dir_is_idempotent() { + let dir = tempdir(); + let trust = dir.path().join("trust"); + create_trust_store_dir(&trust).unwrap(); + create_trust_store_dir(&trust).unwrap(); + } + + // ─── validate ─────────────────────────────────────────────────────── + + struct Fixture { + _dir: tempfile::TempDir, + anchors: Anchors, + } + + fn fixture() -> Fixture { + let dir = tempdir(); + let trust_store = dir.path().join("state/airlock/trust"); + std::fs::create_dir_all(trust_store.parent().unwrap()).unwrap(); + let global_config = dir.path().join("config/airlock/airlock.toml"); + std::fs::create_dir_all(global_config.parent().unwrap()).unwrap(); + // Lives under its own subtree, disjoint from trust_store/global_config, + // so a grant that is a strict ancestor of just the runtime base (the + // macOS $TMPDIR exception) does not also cover the other anchors. + let runtime_base = dir.path().join("rt-area").join("run"); + std::fs::create_dir_all(&runtime_base).unwrap(); + std::fs::set_permissions(&runtime_base, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let anchors = Anchors { + trust_store, + trust_store_source: None, + global_config, + global_config_source: None, + tool_state_base: dir.path().join("cache/airlock"), + runtime_base, + }; + Fixture { _dir: dir, anchors } + } + + #[test] + fn validate_creates_missing_trust_store_and_succeeds() { + let f = fixture(); + assert!(!f.anchors.trust_store.exists()); + validate(&f.anchors, None, &[]).expect("validate"); + assert!(f.anchors.trust_store.is_dir()); + } + + #[test] + fn validate_rejects_group_writable_trust_store() { + let f = fixture(); + std::fs::create_dir_all(&f.anchors.trust_store).unwrap(); + std::fs::set_permissions( + &f.anchors.trust_store, + std::fs::Permissions::from_mode(0o750), + ) + .unwrap(); + + assert!(matches!( + validate(&f.anchors, None, &[]), + Err(AnchorError::BadMode { .. }) + )); + } + + #[test] + fn validate_rejects_trust_store_symlink() { + let f = fixture(); + let real = f.anchors.trust_store.parent().unwrap().join("real-trust"); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&real) + .unwrap(); + std::os::unix::fs::symlink(&real, &f.anchors.trust_store).unwrap(); + + assert!(matches!( + validate(&f.anchors, None, &[]), + Err(AnchorError::IsSymlink { .. }) + )); + } + + #[test] + fn validate_accepts_global_config_absent() { + let f = fixture(); + assert!(!f.anchors.global_config.exists()); + validate(&f.anchors, None, &[]).expect("absent global config is fine"); + } + + #[test] + fn validate_rejects_group_writable_global_config() { + let f = fixture(); + std::fs::write(&f.anchors.global_config, b"").unwrap(); + std::fs::set_permissions( + &f.anchors.global_config, + std::fs::Permissions::from_mode(0o664), + ) + .unwrap(); + + assert!(matches!( + validate(&f.anchors, None, &[]), + Err(AnchorError::GroupOrWorldWritable { .. }) + )); + } + + #[test] + fn validate_accepts_normal_mode_global_config() { + let f = fixture(); + std::fs::write(&f.anchors.global_config, b"").unwrap(); + std::fs::set_permissions( + &f.anchors.global_config, + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + + validate(&f.anchors, None, &[]).expect("0644 global config is fine"); + } + + #[test] + fn validate_rejects_anchor_inside_project_root() { + let f = fixture(); + let root = f.anchors.trust_store.parent().unwrap().parent().unwrap(); + + assert!(matches!( + validate(&f.anchors, Some(root), &[]), + Err(AnchorError::InsideRoot { .. }) + )); + } + + #[test] + fn validate_rejects_write_grant_covering_runtime_base() { + let f = fixture(); + let grant = f.anchors.runtime_base.clone(); + + assert!(matches!( + validate(&f.anchors, None, &[grant]), + Err(AnchorError::Covered { .. }) + )); + } + + #[test] + fn validate_rejects_write_grant_inside_runtime_base() { + let f = fixture(); + let grant = f.anchors.runtime_base.join("inside"); + + assert!(matches!( + validate(&f.anchors, None, &[grant]), + Err(AnchorError::Covered { .. }) + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn validate_allows_strict_ancestor_grant_of_runtime_base_on_macos() { + let f = fixture(); + let grant = f.anchors.runtime_base.parent().unwrap().to_path_buf(); + + validate(&f.anchors, None, &[grant]).expect("macOS $TMPDIR ancestor exception"); + } + + #[test] + fn validate_rejects_grant_covering_unrelated_anchor_without_exception() { + let f = fixture(); + // The ancestor exception is specific to the runtime base; a grant + // that is a strict ancestor of the trust store is always refused, + // on every platform. + let grant = f.anchors.trust_store.parent().unwrap().to_path_buf(); + + assert!(matches!( + validate(&f.anchors, None, &[grant]), + Err(AnchorError::Covered { .. }) + )); + } + + // ─── check_grants_against_anchors ────────────────────────────────── + + #[test] + fn check_grants_against_anchors_reports_file_and_anchor() { + let f = fixture(); + std::fs::create_dir_all(&f.anchors.trust_store).unwrap(); + let grants = [Grant { + file: "airlock.toml", + path: &f.anchors.trust_store, + }]; + + let err = check_grants_against_anchors(&f.anchors, &grants).unwrap_err(); + let message = err.to_string(); + assert!(message.starts_with("airlock.toml: filesystem.write \"")); + assert!(message.contains("covers trust store")); + assert!(message.ends_with("Airlock's own files cannot be writable from a sandbox")); + } + + #[test] + fn check_grants_against_anchors_allows_disjoint_grant() { + let f = fixture(); + let elsewhere = f.anchors.trust_store.parent().unwrap().join("elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let grants = [Grant { + file: "airlock.toml", + path: &elsewhere, + }]; + + check_grants_against_anchors(&f.anchors, &grants).expect("disjoint grant is fine"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8eef957..a39db4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,16 @@ +pub mod anchors; pub mod client; pub mod config; pub mod daemon; pub mod exec; pub mod policy; +pub mod process_tree; pub mod protocol; pub mod proxy; pub mod redact; pub mod refresh; pub mod run; +pub mod runtime_dir; pub mod sandbox; pub mod secrets; pub mod trust; diff --git a/src/process_tree.rs b/src/process_tree.rs new file mode 100644 index 0000000..b1c6f45 --- /dev/null +++ b/src/process_tree.rs @@ -0,0 +1,240 @@ +//! Process identity and ancestry, used to bind a session token to the +//! process tree that received it. +//! +//! A session token sits in the environment of every process the harness +//! starts. Any unsandboxed process of the same uid can read it — through +//! `KERN_PROCARGS2` on macOS or `/proc//environ` on Linux — so the +//! token alone is not enough. The daemon additionally takes the peer PID +//! off the socket and checks that the session's anchor process is one of +//! its ancestors. The anchor is recorded together with its start time, so a +//! reused PID cannot forge membership once the original process has exited. + +use std::io; + +/// A process identity: its pid and start time. Equality requires both — +/// a reused pid with a different start time is a different process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcId { + pub pid: i32, + pub start: u64, +} + +/// Looks up a process's identity (pid plus start time). +pub fn proc_id(pid: i32) -> io::Result { + Ok(ProcId { + pid, + start: start_time(pid)?, + }) +} + +/// Looks up a process's parent pid. +pub fn parent_pid(pid: i32) -> io::Result { + platform::parent_pid(pid) +} + +fn start_time(pid: i32) -> io::Result { + platform::start_time(pid) +} + +/// Walks the parent chain starting at `pid` (which counts as its own +/// ancestor) looking for `anchor`, stopping at pid 1 or 0 or after 256 +/// steps. A process that cannot be inspected (already exited, or a +/// permission error) is treated as not a descendant. +pub fn is_descendant_of(pid: i32, anchor: &ProcId) -> bool { + let mut current = pid; + + for _ in 0..256 { + let Ok(id) = proc_id(current) else { + return false; + }; + if id == *anchor { + return true; + } + if current <= 1 { + return false; + } + match parent_pid(current) { + Ok(parent) if parent > 0 && parent != current => current = parent, + _ => return false, + } + } + + false +} + +/// The peer pid and uid of a connected Unix stream. +pub fn peer_pid_uid(stream: &tokio::net::UnixStream) -> io::Result<(i32, u32)> { + let cred = stream.peer_cred()?; + let pid = cred + .pid() + .ok_or_else(|| io::Error::other("peer pid unavailable for this platform"))?; + Ok((pid, cred.uid())) +} + +#[cfg(target_os = "macos")] +mod platform { + use std::io; + use std::mem; + + fn bsdinfo(pid: i32) -> io::Result { + let mut info: libc::proc_bsdinfo = unsafe { mem::zeroed() }; + let size = mem::size_of::() as libc::c_int; + let ret = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + &mut info as *mut _ as *mut libc::c_void, + size, + ) + }; + if ret != size { + return Err(io::Error::last_os_error()); + } + Ok(info) + } + + pub(super) fn start_time(pid: i32) -> io::Result { + let info = bsdinfo(pid)?; + // Microsecond resolution is plenty to disambiguate a reused pid; + // it need not match any particular unit, only be stable per process. + Ok(info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec) + } + + pub(super) fn parent_pid(pid: i32) -> io::Result { + Ok(bsdinfo(pid)?.pbi_ppid as i32) + } +} + +#[cfg(target_os = "linux")] +mod platform { + use std::io; + + /// Reads a process's parent pid (field 4) and start time in clock + /// ticks (field 22) from `/proc//stat`. + /// + /// `comm` (field 2) is parenthesized but is not escaped: a process can + /// name itself `1234) 5 6 (7` and the kernel writes it verbatim. The + /// only reliable split is the *last* `)` in the line — fields after it + /// are space-separated and never contain parens themselves — so we + /// locate that instead of assuming `comm` has no spaces or parens. + fn fields(pid: i32) -> io::Result<(i32, u64)> { + let contents = std::fs::read_to_string(format!("/proc/{pid}/stat"))?; + let close = contents.rfind(')').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "malformed /proc//stat: no comm field", + ) + })?; + let rest = contents[close + 1..].trim_start(); + let fields: Vec<&str> = rest.split_whitespace().collect(); + + // `rest` starts at field 3 (state), so field N is at index N - 3. + let ppid = fields + .get(4 - 3) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "malformed /proc//stat: missing ppid", + ) + })? + .parse::() + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "malformed /proc//stat: bad ppid", + ) + })?; + let starttime = fields + .get(22 - 3) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "malformed /proc//stat: missing starttime", + ) + })? + .parse::() + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "malformed /proc//stat: bad starttime", + ) + })?; + + Ok((ppid, starttime)) + } + + pub(super) fn start_time(pid: i32) -> io::Result { + Ok(fields(pid)?.1) + } + + pub(super) fn parent_pid(pid: i32) -> io::Result { + Ok(fields(pid)?.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + fn current_pid() -> i32 { + std::process::id() as i32 + } + + #[test] + fn current_process_is_its_own_descendant() { + let me = proc_id(current_pid()).expect("proc_id(self)"); + assert!(is_descendant_of(current_pid(), &me)); + } + + #[test] + fn spawned_child_is_a_descendant_of_the_test_process() { + let me = proc_id(current_pid()).expect("proc_id(self)"); + let mut child = Command::new("sleep").arg("5").spawn().expect("spawn sleep"); + let child_pid = child.id() as i32; + + assert!(is_descendant_of(child_pid, &me)); + + child.kill().ok(); + child.wait().ok(); + } + + #[test] + fn test_process_is_not_a_descendant_of_its_child() { + let mut child = Command::new("sleep").arg("5").spawn().expect("spawn sleep"); + let child_pid = child.id() as i32; + let child_anchor = proc_id(child_pid).expect("proc_id(child)"); + + assert!(!is_descendant_of(current_pid(), &child_anchor)); + + child.kill().ok(); + child.wait().ok(); + } + + #[test] + fn wrong_start_time_does_not_match() { + let mut me = proc_id(current_pid()).expect("proc_id(self)"); + me.start = me.start.wrapping_add(1); + assert!(!is_descendant_of(current_pid(), &me)); + } + + #[test] + fn parent_pid_of_child_is_the_test_process() { + let mut child = Command::new("sleep").arg("5").spawn().expect("spawn sleep"); + let child_pid = child.id() as i32; + + assert_eq!(parent_pid(child_pid).expect("parent_pid"), current_pid()); + + child.kill().ok(); + child.wait().ok(); + } + + #[tokio::test] + async fn peer_pid_uid_reports_this_process() { + let (a, _b) = tokio::net::UnixStream::pair().expect("UnixStream::pair"); + let (pid, uid) = peer_pid_uid(&a).expect("peer_pid_uid"); + assert_eq!(pid, current_pid()); + assert_eq!(uid, unsafe { libc::geteuid() }); + } +} diff --git a/src/runtime_dir.rs b/src/runtime_dir.rs new file mode 100644 index 0000000..15b0fc5 --- /dev/null +++ b/src/runtime_dir.rs @@ -0,0 +1,504 @@ +//! The daemon's per-user runtime directory: socket, PID file, admin token +//! and proxy CA certificates. +//! +//! The base is never taken from `TMPDIR` or `XDG_RUNTIME_DIR` — both vary +//! between shells of the same user (Nix shells, tmux, `sudo -u`, containers), +//! which would let two shells disagree about where the daemon lives. macOS +//! uses `confstr(_CS_DARWIN_USER_TEMP_DIR)`; Linux uses `/run/user/` +//! when the system has set it up correctly, falling back to a predictable +//! `/tmp` path otherwise. + +use std::io; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{DirBuilderExt, MetadataExt}; +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +const SOCKET_FILENAME: &str = "airlock.sock"; +const PID_FILENAME: &str = "airlock.pid"; +const ADMIN_TOKEN_FILENAME: &str = "admin.token"; +const CA_DIRNAME: &str = "ca"; + +/// Debug-only escape hatch for integration tests, which cannot rely on a +/// fixed, real per-user runtime directory across machines. Release builds +/// never read the environment for the runtime base. +#[cfg(debug_assertions)] +const TEST_OVERRIDE_VAR: &str = "AIRLOCK_TEST_RUNTIME_DIR"; + +/// Maximum length of a `sun_path` on this platform, including the +/// terminating NUL `bind(2)` requires `sockaddr_un` to hold. +#[cfg(target_os = "macos")] +const SUN_PATH_CAPACITY: usize = 104; +#[cfg(target_os = "linux")] +const SUN_PATH_CAPACITY: usize = 108; + +/// The daemon's per-user runtime directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeDir { + base: PathBuf, +} + +impl RuntimeDir { + /// Locates the runtime base for this platform. + /// + /// Never reads `TMPDIR` or `XDG_RUNTIME_DIR`. Under `cfg(debug_assertions)` + /// only, `AIRLOCK_TEST_RUNTIME_DIR` (an absolute path) replaces the base + /// outright, for integration tests that need a predictable location. + pub fn locate() -> Result { + #[cfg(debug_assertions)] + if let Some(value) = std::env::var_os(TEST_OVERRIDE_VAR) { + let base = PathBuf::from(value); + if !base.is_absolute() { + return Err(RuntimeDirError::TestOverrideNotAbsolute { path: base }); + } + return Ok(Self { base }); + } + + Self::locate_platform() + } + + #[cfg(target_os = "macos")] + fn locate_platform() -> Result { + let raw = darwin_user_temp_dir()?; + let canonical = std::fs::canonicalize(&raw) + .map_err(|source| RuntimeDirError::Canonicalize { path: raw, source })?; + Ok(Self { + base: canonical.join("airlock"), + }) + } + + #[cfg(target_os = "linux")] + fn locate_platform() -> Result { + let uid = unsafe { libc::geteuid() }; + let run_user = PathBuf::from(format!("/run/user/{uid}")); + if run_user_dir_usable(&run_user, uid) { + Ok(Self { + base: run_user.join("airlock"), + }) + } else { + Ok(Self { + base: PathBuf::from(format!("/tmp/airlock-{uid}")), + }) + } + } + + /// Builds a `RuntimeDir` directly from a base path. For tests, and for + /// the debug-only test override. + pub fn at(base: PathBuf) -> Self { + Self { base } + } + + pub fn base(&self) -> &Path { + &self.base + } + + /// Creates `` and `/ca` with mode 0700 if they do not exist, + /// then validates both, then checks that the socket path fits in a + /// `sockaddr_un`. + pub fn create_and_validate(&self) -> Result<(), RuntimeDirError> { + ensure_dir_0700(&self.base)?; + self.validate()?; + + let ca = self.ca_dir(); + ensure_dir_0700(&ca)?; + validate_dir(&ca, "runtime CA directory")?; + + self.check_socket_path_length() + } + + /// Validates ``: it must be a directory, not a symlink, owned by + /// the effective uid, and restricted to the owner (`mode & 0o077 == 0`). + pub fn validate(&self) -> Result<(), RuntimeDirError> { + validate_dir(&self.base, "runtime dir") + } + + fn check_socket_path_length(&self) -> Result<(), RuntimeDirError> { + let path = self.socket_path(); + // sockaddr_un needs room for a trailing NUL after the path bytes. + let len = path.as_os_str().as_bytes().len(); + if len >= SUN_PATH_CAPACITY { + return Err(RuntimeDirError::SocketPathTooLong { + path, + len, + max: SUN_PATH_CAPACITY - 1, + }); + } + Ok(()) + } + + pub fn socket_path(&self) -> PathBuf { + self.base.join(SOCKET_FILENAME) + } + + pub fn pid_path(&self) -> PathBuf { + self.base.join(PID_FILENAME) + } + + pub fn admin_token_path(&self) -> PathBuf { + self.base.join(ADMIN_TOKEN_FILENAME) + } + + pub fn ca_dir(&self) -> PathBuf { + self.base.join(CA_DIRNAME) + } + + pub fn ca_path(&self, session_id: &str) -> PathBuf { + self.ca_dir().join(format!("{session_id}.pem")) + } + + pub fn addr(&self) -> String { + format!("unix://{}", self.socket_path().display()) + } +} + +/// Parses a daemon address. Only `unix://` addresses are accepted; the +/// runtime directory is never discovered from a network address. +pub fn parse_addr(addr: &str) -> Result { + addr.strip_prefix("unix://") + .map(PathBuf::from) + .ok_or_else(|| RuntimeDirError::UnsupportedAddr { + addr: addr.to_string(), + }) +} + +#[cfg(target_os = "macos")] +fn darwin_user_temp_dir() -> Result { + let mut len = unsafe { libc::confstr(libc::_CS_DARWIN_USER_TEMP_DIR, std::ptr::null_mut(), 0) }; + if len == 0 { + return Err(RuntimeDirError::Confstr { + source: io::Error::last_os_error(), + }); + } + + let mut buf = vec![0u8; len]; + len = unsafe { + libc::confstr( + libc::_CS_DARWIN_USER_TEMP_DIR, + buf.as_mut_ptr() as *mut libc::c_char, + buf.len(), + ) + }; + if len == 0 || len > buf.len() { + return Err(RuntimeDirError::Confstr { + source: io::Error::last_os_error(), + }); + } + + // confstr's returned length includes the trailing NUL. + buf.truncate(len - 1); + Ok(PathBuf::from(std::ffi::OsStr::from_bytes(&buf))) +} + +#[cfg(target_os = "linux")] +fn run_user_dir_usable(path: &Path, uid: u32) -> bool { + match std::fs::symlink_metadata(path) { + Ok(meta) => meta.is_dir() && meta.uid() == uid && meta.mode() & 0o777 == 0o700, + Err(_) => false, + } +} + +fn ensure_dir_0700(path: &Path) -> Result<(), RuntimeDirError> { + match std::fs::DirBuilder::new().mode(0o700).create(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), + Err(source) => Err(RuntimeDirError::Create { + path: path.to_path_buf(), + source, + }), + } +} + +fn validate_dir(path: &Path, label: &str) -> Result<(), RuntimeDirError> { + let meta = std::fs::symlink_metadata(path).map_err(|source| RuntimeDirError::Missing { + label: label.to_string(), + path: path.to_path_buf(), + source, + })?; + + if meta.file_type().is_symlink() { + return Err(RuntimeDirError::IsSymlink { + label: label.to_string(), + path: path.to_path_buf(), + }); + } + if !meta.is_dir() { + return Err(RuntimeDirError::NotADirectory { + label: label.to_string(), + path: path.to_path_buf(), + }); + } + + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + return Err(RuntimeDirError::WrongOwner { + label: label.to_string(), + path: path.to_path_buf(), + owner: meta.uid(), + euid, + }); + } + + let mode = meta.mode() & 0o777; + if mode & 0o077 != 0 { + return Err(RuntimeDirError::BadMode { + label: label.to_string(), + path: path.to_path_buf(), + mode, + }); + } + + Ok(()) +} + +/// Errors from locating, creating or validating the runtime directory. +#[derive(Debug, Error)] +pub enum RuntimeDirError { + #[cfg(target_os = "macos")] + #[error("could not determine the per-user temp directory (confstr failed): {source}")] + Confstr { source: io::Error }, + + #[cfg(target_os = "macos")] + #[error("could not resolve the per-user temp directory {}: {source}", path.display())] + Canonicalize { path: PathBuf, source: io::Error }, + + #[error("could not create runtime dir {}: {source}", path.display())] + Create { path: PathBuf, source: io::Error }, + + #[error("{label} {} does not exist: {source}", path.display())] + Missing { + label: String, + path: PathBuf, + source: io::Error, + }, + + #[error("{label} {} is a symlink", path.display())] + IsSymlink { label: String, path: PathBuf }, + + #[error("{label} {} is not a directory", path.display())] + NotADirectory { label: String, path: PathBuf }, + + #[error("{label} {} is owned by uid {owner}, not you ({euid})", path.display())] + WrongOwner { + label: String, + path: PathBuf, + owner: u32, + euid: u32, + }, + + #[error("{label} {} has mode {mode:03o}, expected 0700", path.display())] + BadMode { + label: String, + path: PathBuf, + mode: u32, + }, + + #[error( + "runtime socket path {} is {len} bytes, which exceeds the {max}-byte limit for a Unix socket path on this platform", + path.display() + )] + SocketPathTooLong { + path: PathBuf, + len: usize, + max: usize, + }, + + #[error("unsupported address {addr:?}: only unix:// addresses are accepted")] + UnsupportedAddr { addr: String }, + + #[cfg(debug_assertions)] + #[error("AIRLOCK_TEST_RUNTIME_DIR must be an absolute path, got {}", path.display())] + TestOverrideNotAbsolute { path: PathBuf }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::ENV_MUTEX; + use std::os::unix::fs::symlink; + + fn tempdir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + #[test] + fn socket_pid_admin_ca_paths_join_base() { + let rt = RuntimeDir::at(PathBuf::from("/base")); + assert_eq!(rt.socket_path(), PathBuf::from("/base/airlock.sock")); + assert_eq!(rt.pid_path(), PathBuf::from("/base/airlock.pid")); + assert_eq!(rt.admin_token_path(), PathBuf::from("/base/admin.token")); + assert_eq!(rt.ca_dir(), PathBuf::from("/base/ca")); + assert_eq!(rt.ca_path("abc123"), PathBuf::from("/base/ca/abc123.pem")); + } + + #[test] + fn addr_is_unix_scheme_plus_socket_path() { + let rt = RuntimeDir::at(PathBuf::from("/base")); + assert_eq!(rt.addr(), "unix:///base/airlock.sock"); + } + + #[test] + fn parse_addr_accepts_unix_scheme() { + assert_eq!( + parse_addr("unix:///base/airlock.sock").unwrap(), + PathBuf::from("/base/airlock.sock") + ); + } + + #[test] + fn parse_addr_rejects_other_schemes() { + assert!(parse_addr("tcp://127.0.0.1:1234").is_err()); + assert!(parse_addr("/base/airlock.sock").is_err()); + } + + #[test] + fn create_and_validate_creates_base_and_ca_with_mode_0700() { + let dir = tempdir(); + let base = dir.path().join("rt"); + let rt = RuntimeDir::at(base.clone()); + + rt.create_and_validate().expect("create_and_validate"); + + let base_mode = std::fs::metadata(&base).unwrap().mode() & 0o777; + let ca_mode = std::fs::metadata(rt.ca_dir()).unwrap().mode() & 0o777; + assert_eq!(base_mode, 0o700); + assert_eq!(ca_mode, 0o700); + } + + #[test] + fn create_and_validate_is_idempotent() { + let dir = tempdir(); + let base = dir.path().join("rt"); + let rt = RuntimeDir::at(base); + + rt.create_and_validate().expect("first call"); + rt.create_and_validate().expect("second call"); + } + + #[test] + fn validate_rejects_missing_directory() { + let dir = tempdir(); + let rt = RuntimeDir::at(dir.path().join("missing")); + assert!(matches!( + rt.validate(), + Err(RuntimeDirError::Missing { .. }) + )); + } + + #[test] + fn validate_rejects_symlink() { + let dir = tempdir(); + let real = dir.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let link = dir.path().join("link"); + symlink(&real, &link).unwrap(); + + let rt = RuntimeDir::at(link); + assert!(matches!( + rt.validate(), + Err(RuntimeDirError::IsSymlink { .. }) + )); + } + + #[test] + fn validate_rejects_non_directory() { + let dir = tempdir(); + let file = dir.path().join("afile"); + std::fs::write(&file, b"x").unwrap(); + + let rt = RuntimeDir::at(file); + assert!(matches!( + rt.validate(), + Err(RuntimeDirError::NotADirectory { .. }) + )); + } + + #[test] + fn validate_rejects_group_or_world_accessible_mode() { + let dir = tempdir(); + let base = dir.path().join("rt"); + std::fs::DirBuilder::new() + .mode(0o750) + .create(&base) + .unwrap(); + + let rt = RuntimeDir::at(base); + assert!(matches!( + rt.validate(), + Err(RuntimeDirError::BadMode { .. }) + )); + } + + #[test] + fn validate_accepts_mode_0700() { + let dir = tempdir(); + let base = dir.path().join("rt"); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&base) + .unwrap(); + + let rt = RuntimeDir::at(base); + rt.validate().expect("mode 0700 is valid"); + } + + #[test] + fn create_and_validate_rejects_socket_path_over_sun_path_limit() { + let dir = tempdir(); + // Pad the base with a long component so the resulting socket path + // exceeds the platform's sun_path capacity (104 on macOS, 108 on + // Linux), without needing a path so long the filesystem itself + // would refuse it. + let padding = "x".repeat(120); + let base = dir.path().join(padding); + let rt = RuntimeDir::at(base); + + assert!(matches!( + rt.create_and_validate(), + Err(RuntimeDirError::SocketPathTooLong { .. }) + )); + } + + #[test] + fn locate_honors_debug_test_override() { + let _guard = ENV_MUTEX.lock().unwrap(); + let dir = tempdir(); + let path = dir.path().join("override-base"); + + unsafe { + std::env::set_var(TEST_OVERRIDE_VAR, &path); + } + let result = RuntimeDir::locate(); + unsafe { + std::env::remove_var(TEST_OVERRIDE_VAR); + } + + assert_eq!(result.unwrap().base(), path.as_path()); + } + + #[test] + fn locate_rejects_relative_test_override() { + let _guard = ENV_MUTEX.lock().unwrap(); + + unsafe { + std::env::set_var(TEST_OVERRIDE_VAR, "relative/path"); + } + let result = RuntimeDir::locate(); + unsafe { + std::env::remove_var(TEST_OVERRIDE_VAR); + } + + assert!(matches!( + result, + Err(RuntimeDirError::TestOverrideNotAbsolute { .. }) + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn locate_platform_resolves_under_darwin_user_temp_dir() { + let rt = RuntimeDir::locate_platform().expect("locate_platform"); + assert!(rt.base().ends_with("airlock")); + assert!(rt.base().is_absolute()); + } +} From 73be2fe32b5016997117ac3746a18ab3dc59923b Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Tue, 6 Oct 2026 23:34:25 +0300 Subject: [PATCH 003/119] feat(protocol): rewrite the wire protocol for v2 sessions 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__<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. --- Cargo.lock | 3 + Cargo.toml | 2 +- src/client.rs | 6 +- src/daemon.rs | 2 +- src/protocol.rs | 2390 +++++++++++++++++++++++++++---- tests/concurrent_integration.rs | 2 +- tests/daemon_integration.rs | 2 +- tests/disconnect_integration.rs | 2 +- tests/e2e_helpers.rs | 2 +- tests/logs_integration.rs | 4 +- tests/redact_e2e_integration.rs | 2 +- tests/shutdown_integration.rs | 2 +- tests/stdin_integration.rs | 2 +- tests/timeout_integration.rs | 2 +- 14 files changed, 2153 insertions(+), 270 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99e60b4..3e72b9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1479,6 +1479,9 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "serde", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index d62d645..56b7748 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/client.rs b/src/client.rs index 6f9d12f..7d302c6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -21,7 +21,7 @@ use std::path::{Path, PathBuf}; use thiserror::Error; use crate::config::{self, ConfigError}; -use crate::protocol::{ClientMessage, DaemonMessage}; +use crate::protocol::v1::{ClientMessage, DaemonMessage}; // ─── Error type ─────────────────────────────────────────────────────────────── @@ -923,11 +923,11 @@ mod tests { let response = DaemonMessage::LogsResponse { entries: vec![ - crate::protocol::LogEntry { + crate::protocol::v1::LogEntry { timestamp: "2025-01-15T10:30:00Z".to_string(), message: "daemon started".to_string(), }, - crate::protocol::LogEntry { + crate::protocol::v1::LogEntry { timestamp: "2025-01-15T10:30:05Z".to_string(), message: "connection accepted".to_string(), }, diff --git a/src/daemon.rs b/src/daemon.rs index 956466a..29caf09 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -34,7 +34,7 @@ use tokio::task::JoinSet; use crate::config::{self, Config, ConfigError}; use crate::exec; use crate::policy; -use crate::protocol::{ClientMessage, DaemonMessage, LogEntry}; +use crate::protocol::v1::{ClientMessage, DaemonMessage, LogEntry}; use crate::proxy::ca::{CaError, ProxyCa}; use crate::proxy::server::{ProxySession, ProxyShared}; use crate::redact::{self, RedactError, Redactor, RedactorSwap}; diff --git a/src/protocol.rs b/src/protocol.rs index 737417a..6e03a4d 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1,130 +1,1414 @@ -//! NDJSON protocol message types for daemon–client communication. +//! NDJSON wire protocol for the v2 daemon. //! -//! This module defines the wire format for all messages exchanged between the -//! Airlock daemon and its CLI client over a Unix domain socket. The protocol -//! uses newline-delimited JSON (NDJSON): each message is a single line of valid -//! JSON terminated by a newline character. +//! One daemon serves every project through sessions ([Sessions] in +//! docs/airlock-v2-design.md). Two message families ride the same Unix +//! socket, split in code as well as on the wire because they are gated +//! differently: the *admin* family (`Register`, `Reload`, session +//! list/revoke/renew, `Tools`, `Logs`, `Stop`) needs `admin.token` and comes +//! only from a launcher outside any sandbox; the *session* family (`Exec`, +//! `List`, `Check`) needs a session token bound to a process tree. See +//! docs/airlock-v2-technical-guidance.md, "Two message families" and "A +//! principal on every request". //! -//! Two distinct enum families are provided: +//! Connection shape: +//! 1. The client sends [`ClientHello`] on the first line. +//! 2. The daemon answers with [`DaemonMessage::Hello`]. +//! 3. The client sends exactly one [`Request`]. +//! 4. For [`SessionRequest::Exec`], the client may follow with any number of +//! [`StdinFrame::Stdin`] lines, then [`StdinFrame::StdinEof`]. These are +//! not `SessionRequest` variants: a connection carries one `Request`, and +//! `Stdin`/`StdinEof` are a separate, smaller vocabulary that only makes +//! sense while an `Exec` is in flight. +//! 5. The daemon streams back zero or more [`DaemonMessage`] lines +//! (`Stdout`/`Stderr`/...) ending in `Exit`, `Error`, or one of the +//! admin-family responses. //! -//! - [`ClientMessage`] — messages sent from the client to the daemon. -//! - [`DaemonMessage`] — messages sent from the daemon to the client. -//! -//! Keeping these as separate types ensures that each side of the socket only -//! sends one family and receives the other, enforced at compile time. -//! -//! All types derive [`serde::Serialize`] and [`serde::Deserialize`] with an -//! internally-tagged representation (`"type"` field) so the receiver can -//! determine which variant it received from a single JSON object. +//! Every request-shaped type denies unknown fields +//! (docs/airlock-v2-technical-guidance.md, "Config on the wire fails +//! closed"): a daemon built against an older protocol version rejects a +//! request carrying a field it doesn't understand instead of silently +//! ignoring a policy the launcher expected enforced. [`PROTOCOL_VERSION`] is +//! the backstop that catches most of this earlier, in the handshake. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::PathBuf; + +use base64::Engine; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use subtle::ConstantTimeEq; +use thiserror::Error; +use zeroize::Zeroizing; + +// ─── Protocol version and frame limits ─────────────────────────────────────── -use serde::{Deserialize, Serialize}; +/// The wire protocol version. Bumped whenever a message shape changes, +/// including the `Register` config payload. Two binaries with the same +/// value interoperate; the launcher's "incompatible daemon" error compares +/// this, not the binary version (docs/airlock-v2-technical-guidance.md, +/// "One protocol version, separate from the binary version"). +pub const PROTOCOL_VERSION: u32 = 2; -// ─── Client-to-daemon messages ─────────────────────────────────────────────── +/// NDJSON line cap for the session family, which comes from a sandboxed, +/// untrusted client. +pub const MAX_SESSION_LINE_BYTES: usize = 1024 * 1024; -/// A message sent from the client to the daemon. +/// NDJSON line cap for the admin family. `Register` carries a merged config, +/// an environment snapshot, a filtered `PATH` and secret values, so it needs +/// more room than the session cap; it is still bounded because the launcher +/// that sends it is trusted (docs/airlock-v2-technical-guidance.md, "Admin +/// frames have their own size limit"). +pub const MAX_ADMIN_LINE_BYTES: usize = 16 * 1024 * 1024; + +// ─── Wire config placeholder ───────────────────────────────────────────────── + +/// Placeholder for the merged config carried by `Register`/`Reload`. /// -/// Each variant is serialized as a JSON object with a `"type"` discriminator -/// field. For example, an `Exec` message serializes as: +/// The real type is `config::RawConfig` (the TOML raw types), which another +/// phase-1 agent is making `Serialize`/`Deserialize` with `deny_unknown_fields` +/// at every level, in parallel, in a different worktree. Depending on it here +/// would couple this module to work in flight on another branch. The +/// coordinator replaces this alias with `config::RawConfig` at merge; nothing +/// else in this module needs to change, since every type that embeds it only +/// ever treats it as an opaque, serializable blob. +pub type WireConfig = serde_json::Value; + +// ─── NDJSON line encoding ───────────────────────────────────────────────────── + +/// Serialize `value` as a single NDJSON line: compact JSON followed by `\n`. /// -/// ```json -/// {"type":"exec","tool":"grep","args":["pattern","file.txt"],"cwd":"/home/user/project"} -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ClientMessage { - /// Request tool execution. +/// `serde_json` escapes control characters (including raw newlines) inside +/// JSON strings, so a well-behaved `Serialize` impl can never produce an +/// embedded `\n`. The assertion exists to catch a future custom `Serialize` +/// impl that writes raw bytes some other way. +pub fn encode_line(value: &T) -> Vec { + let mut bytes = + serde_json::to_vec(value).expect("protocol message types always serialize to JSON"); + assert!( + !bytes.contains(&b'\n'), + "serialized message must not contain an embedded newline" + ); + bytes.push(b'\n'); + bytes +} + +// ─── Parse errors for id/token types ───────────────────────────────────────── + +/// Errors parsing a [`SessionId`], [`SessionToken`] or [`AdminToken`] from +/// its wire string form. +/// +/// Deliberately carries no detail about *why* a token was malformed: unlike +/// a session id, a token string is sensitive, and echoing fragments of a +/// rejected one back in an error message is the kind of thing that ends up +/// in a log. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ProtocolError { + /// Not 6 lowercase hex characters. + #[error("invalid session id {0:?}: must be 6 lowercase hex characters")] + InvalidSessionId(String), + /// Not `airlock_<6 hex>_<43 base64url-no-pad>` decoding to 32 bytes. + #[error("invalid session token")] + InvalidSessionToken, + /// Not 64 lowercase hex characters. + #[error("invalid admin token")] + InvalidAdminToken, +} + +/// Lowercase-hex-encode `bytes`. Hand-rolled rather than pulling in a `hex` +/// crate for six lines of code; [`src/redact.rs`](../redact.rs) does the same. +fn hex_encode(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(DIGITS[(b >> 4) as usize] as char); + out.push(DIGITS[(b & 0x0f) as usize] as char); + } + out +} + +fn is_lowercase_hex_byte(b: u8) -> bool { + b.is_ascii_digit() || (b'a'..=b'f').contains(&b) +} + +fn is_base64url_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'-' || b == b'_' +} + +/// Constant-time byte equality, used by every token's `PartialEq` so that +/// `==` on a token is never a timing side channel. +fn ct_eq_bytes(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && bool::from(a.ct_eq(b)) +} + +// ─── SessionId ──────────────────────────────────────────────────────────────── + +const SESSION_ID_LEN: usize = 6; + +/// A session identifier: 6 lowercase hex characters. +/// +/// Not secret — it is shown in `session list`, logged with every `exec`, and +/// is the first 6 characters an operator reads off a token. Only the token's +/// random part is a bearer credential. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct SessionId(String); + +impl SessionId { + /// Generate a fresh random id. The daemon regenerates on collision; this + /// function always returns a well-formed id, never a guaranteed-unique + /// one. + pub fn generate() -> Self { + let mut buf = [0u8; SESSION_ID_LEN / 2]; + getrandom::fill(&mut buf).expect("the system RNG must be available"); + SessionId(hex_encode(&buf)) + } + + /// Parse a 6-lowercase-hex-character id. + pub fn parse(s: &str) -> Result { + if s.len() == SESSION_ID_LEN && s.bytes().all(is_lowercase_hex_byte) { + Ok(SessionId(s.to_string())) + } else { + Err(ProtocolError::InvalidSessionId(s.to_string())) + } + } + + /// The id as a plain string, e.g. for display or as a map key. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for SessionId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Serialize for SessionId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for SessionId { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + SessionId::parse(&s).map_err(serde::de::Error::custom) + } +} + +// ─── SessionToken ───────────────────────────────────────────────────────────── + +/// `airlock_`, then 6 lowercase hex chars (the session id), then `_`, then +/// 43 base64url-no-pad characters encoding 32 random bytes. +const TOKEN_PREFIX: &str = "airlock_"; +const TOKEN_SECRET_LEN: usize = 43; +const TOKEN_SECRET_BYTES: usize = 32; + +/// A session bearer token. +/// +/// The prefix and embedded id let a secret scanner recognize the token and +/// let logs name the session without the secret part (UX doc, "Open UX +/// questions" — session id and token format). `Debug` prints only the id; +/// `PartialEq` compares the secret part in constant time, so `==` is never a +/// timing side channel and there is no faster-but-unsafe alternative to reach +/// for by mistake. The secret part zeroizes on drop. +pub struct SessionToken { + id: SessionId, + secret: Zeroizing, +} + +impl SessionToken { + /// Mint a fresh token for `id` from 32 bytes of CSPRNG output. + pub fn generate(id: SessionId) -> Self { + let mut buf = [0u8; TOKEN_SECRET_BYTES]; + getrandom::fill(&mut buf).expect("the system RNG must be available"); + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf); + debug_assert_eq!(secret.len(), TOKEN_SECRET_LEN); + SessionToken { + id, + secret: Zeroizing::new(secret), + } + } + + /// Parse `airlock__`, validating the fixed layout and that + /// the secret decodes to exactly 32 bytes. /// - /// Carries the tool name, argument list, and working directory path for the - /// child process. - Exec { - /// The bare tool name as declared in `airlock.toml` (e.g., `"grep"`). - tool: String, - /// Arguments to pass to the tool. - args: Vec, - /// The client's working directory, sent as an absolute path string. - cwd: String, + /// Parses on raw bytes and only converts to `&str` after confirming + /// every byte is ASCII in the expected ranges, so a malformed, + /// adversary-controlled string can't panic this on a UTF-8 char + /// boundary. + pub fn parse(s: &str) -> Result { + let rest = s + .strip_prefix(TOKEN_PREFIX) + .ok_or(ProtocolError::InvalidSessionToken)?; + let bytes = rest.as_bytes(); + if bytes.len() != SESSION_ID_LEN + 1 + TOKEN_SECRET_LEN { + return Err(ProtocolError::InvalidSessionToken); + } + let (id_bytes, tail) = bytes.split_at(SESSION_ID_LEN); + let (sep, secret_bytes) = tail.split_at(1); + let shape_ok = id_bytes.iter().copied().all(is_lowercase_hex_byte) + && sep == b"_" + && secret_bytes.iter().copied().all(is_base64url_byte); + if !shape_ok { + return Err(ProtocolError::InvalidSessionToken); + } + // Safe: every byte above was checked to be ASCII in a known range. + let id_str = std::str::from_utf8(id_bytes).expect("validated ASCII above"); + let secret_str = std::str::from_utf8(secret_bytes) + .expect("validated ASCII above") + .to_string(); + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&secret_str) + .map_err(|_| ProtocolError::InvalidSessionToken)?; + if decoded.len() != TOKEN_SECRET_BYTES { + return Err(ProtocolError::InvalidSessionToken); + } + Ok(SessionToken { + id: SessionId(id_str.to_string()), + secret: Zeroizing::new(secret_str), + }) + } + + /// The session this token authenticates. Not secret. + pub fn id(&self) -> &SessionId { + &self.id + } + + /// Render the full token string that crosses the socket. Named like + /// `Secret::expose_secret` ([src/secrets.rs](../secrets.rs)) so every + /// call site that exposes the value is grep-able. + pub fn expose_secret(&self) -> String { + format!("{TOKEN_PREFIX}{}_{}", self.id.0, *self.secret) + } + + /// Constant-time equality. Same as `==`; provided under this name + /// because call sites that compare a presented token against a stored + /// one read better naming the property they rely on. + pub fn ct_eq(&self, other: &SessionToken) -> bool { + self == other + } +} + +impl PartialEq for SessionToken { + fn eq(&self, other: &Self) -> bool { + self.id == other.id && ct_eq_bytes(self.secret.as_bytes(), other.secret.as_bytes()) + } +} + +impl Eq for SessionToken {} + +impl Clone for SessionToken { + fn clone(&self) -> Self { + SessionToken { + id: self.id.clone(), + secret: self.secret.clone(), + } + } +} + +impl fmt::Debug for SessionToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SessionToken") + .field("id", &self.id) + .field("secret", &"[REDACTED]") + .finish() + } +} + +impl Serialize for SessionToken { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.expose_secret()) + } +} + +impl<'de> Deserialize<'de> for SessionToken { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + SessionToken::parse(&s).map_err(serde::de::Error::custom) + } +} + +// ─── AdminToken ─────────────────────────────────────────────────────────────── + +const ADMIN_TOKEN_LEN: usize = 64; + +/// The daemon's admin credential: 64 lowercase hex characters (32 random +/// bytes), written to `/admin.token` with mode 0600. +/// +/// One per daemon, not per session, so unlike [`SessionToken`] it carries no +/// id. `Debug` never prints it. Zeroizes on drop. +pub struct AdminToken(Zeroizing); + +impl AdminToken { + /// Mint a fresh token from 32 bytes of CSPRNG output. + pub fn generate() -> Self { + let mut buf = [0u8; 32]; + getrandom::fill(&mut buf).expect("the system RNG must be available"); + AdminToken(Zeroizing::new(hex_encode(&buf))) + } + + /// Parse 64 lowercase hex characters. + pub fn parse(s: &str) -> Result { + if s.len() == ADMIN_TOKEN_LEN && s.bytes().all(is_lowercase_hex_byte) { + Ok(AdminToken(Zeroizing::new(s.to_string()))) + } else { + Err(ProtocolError::InvalidAdminToken) + } + } + + /// The raw token string. Named like `Secret::expose_secret` + /// ([src/secrets.rs](../secrets.rs)). + pub fn expose_secret(&self) -> &str { + &self.0 + } + + /// Constant-time equality; see [`SessionToken::ct_eq`]. + pub fn ct_eq(&self, other: &AdminToken) -> bool { + self == other + } +} + +impl PartialEq for AdminToken { + fn eq(&self, other: &Self) -> bool { + ct_eq_bytes(self.0.as_bytes(), other.0.as_bytes()) + } +} + +impl Eq for AdminToken {} + +impl Clone for AdminToken { + fn clone(&self) -> Self { + AdminToken(self.0.clone()) + } +} + +impl fmt::Debug for AdminToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("AdminToken").field(&"[REDACTED]").finish() + } +} + +impl Serialize for AdminToken { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for AdminToken { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + AdminToken::parse(&s).map_err(serde::de::Error::custom) + } +} + +// ─── Handshake ──────────────────────────────────────────────────────────────── + +/// The client's first line on every connection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClientHello { + /// Must equal [`PROTOCOL_VERSION`] for the connection to proceed. + pub protocol: u32, + /// The client binary's version, for the daemon's own diagnostics. + pub version: String, +} + +/// How the daemon was started. Carried in [`DaemonMessage::Hello`] so a +/// launcher can decide whether it may replace an idle daemon. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DaemonMode { + /// Started on demand by a launcher; exits after a grace period with no + /// sessions. + Automatic, + /// Installed via `daemon install`; stays up with no sessions. + Service, + /// Started by `daemon start` by hand. + Manual, +} + +// ─── Auth and request envelope ─────────────────────────────────────────────── + +/// The credential on a [`Request`], resolved to a `Principal` by the Unix +/// listener before any handler runs (docs/airlock-v2-technical-guidance.md, +/// "A principal on every request"). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Auth { + /// A session token, checked against the session's process-tree binding. + Session { + /// The presented token. + token: SessionToken, }, + /// The admin token, checked by constant-time comparison against + /// `admin.token`. + Admin { + /// The presented token. + token: AdminToken, + }, +} +/// The request body, tagged by family so the daemon can check it against +/// `auth.kind` before dispatch — a request whose family doesn't match its +/// auth kind is well-formed on the wire and refused at the handler, with +/// [`ErrorKind::Unauthorized`] (docs/airlock-v2-technical-guidance.md, "Two +/// message families"). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "family", rename_all = "snake_case")] +pub enum RequestBody { + /// An admin-family request. Needs [`Auth::Admin`]. + Admin(AdminRequest), + /// A session-family request. Needs [`Auth::Session`]. + Session(SessionRequest), +} + +/// The one request a client sends per connection, after the handshake. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Request { + /// The presented credential. + pub auth: Auth, + /// What's being asked. + pub body: RequestBody, +} + +/// Follows a [`SessionRequest::Exec`] request, one line per chunk, until +/// `StdinEof`. Not a `SessionRequest` variant: a connection carries exactly +/// one `Request`, and these frames only make sense while that `Exec` is +/// still running. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum StdinFrame { /// A chunk of stdin data to forward to the running child process. Stdin { /// The data chunk, encoded as a UTF-8 string. data: String, }, - - /// Signals that the client has closed its stdin stream. - /// - /// After receiving this message, the daemon closes the child's stdin pipe. + /// The client has closed its stdin stream; the daemon closes the + /// child's stdin pipe. StdinEof, +} + +// ─── Session-family requests ───────────────────────────────────────────────── - /// Requests the daemon's ring-buffer log entries. - Logs, +/// A request served with a session token only. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum SessionRequest { + /// Run a declared tool. Carries nothing that names the daemon's host + /// beyond the working directory (docs/airlock-v2-technical-guidance.md, + /// "`Exec` stays free of host-specific fields"). + Exec { + /// The bare tool name as declared in `airlock.toml`. + tool: String, + /// Arguments to pass to the tool. + args: Vec, + /// The client's working directory. Must lie under the session's + /// root. + cwd: PathBuf, + }, + /// List the tools this session serves. + List, + /// Self-test this session and its sandbox; see `airlock agent check` + /// (docs/airlock-v2-ux.md, "`airlock agent check`"). + Check, } -// ─── Daemon-to-client messages ─────────────────────────────────────────────── +// ─── Admin-family requests ─────────────────────────────────────────────────── -/// A message sent from the daemon to the client. +/// Whether a session's agent runs in Airlock's own sandbox or one the +/// harness brought (docs/airlock-v2-design.md, "Registering a session"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxKind { + /// Started by `airlock run`. + Airlock, + /// Started by `airlock session start`, under some other harness's + /// sandbox or none. + External, +} + +/// What ends a session (docs/airlock-v2-design.md, "Lifetime"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum SessionEnds { + /// Ends when the `Register` connection closes. + Lease, + /// Ends `secs` seconds after registration; `0` means never. + Ttl { + /// Seconds until expiry, or `0` for no expiry. + secs: u64, + }, +} + +/// How the project root was discovered, so a `Reload` repeats exactly the +/// discovery a session started with rather than the current directory's +/// (docs/airlock-v2-design.md, "Reloading a session"). Wire form of +/// `config::DiscoveryMode`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum WireMode { + /// Walked up from the current directory to find `airlock.toml` / + /// `airlock.local.toml`. + Default, + /// `--config `: that file only, root is its parent. + ConfigFile { + /// The explicit config file path. + path: PathBuf, + }, + /// `--no-project-config`: root is the canonical current directory, no + /// project layer. + NoProjectConfig, +} + +/// Which config layer a file belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayerKind { + /// `$XDG_CONFIG_HOME/airlock/airlock.toml`. + Global, + /// `airlock.toml` in the project. + Repo, + /// `airlock.local.toml` in the project. + Local, + /// An explicit `--config ` file. + ConfigFile, +} + +/// One config file contributing to a session, identified by content hash so +/// the trust store and `session list`'s "config changed" can tell whether it +/// still matches what was approved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WireLayer { + /// Which layer this file is. + pub kind: LayerKind, + /// Its path. + pub path: PathBuf, + /// Lowercase hex SHA-256 of its bytes. + pub sha256: String, +} + +/// One `PATH` entry the launcher's filtered `PATH` dropped, and why. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DroppedPath { + /// The entry as it appeared in `PATH`. + pub entry: String, + /// Why it was dropped: relative, inside the project, or writable from a + /// sandbox. + pub reason: String, +} + +/// The anchor paths the launcher validated before registering, echoed back +/// so `agent check`'s probes test exactly what the launcher used +/// (docs/airlock-v2-design.md, "Agent integration"). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WireAnchors { + /// The runtime directory base. + pub runtime_base: PathBuf, + /// The trust store directory. + pub trust_store: PathBuf, + /// The global config file path. + pub global_config: PathBuf, +} + +/// One resolved secret value, labeled for lookup by the tools that reference +/// it. /// -/// Each variant is serialized as a JSON object with a `"type"` discriminator -/// field. For example, a `Stdout` message serializes as: +/// `value` has to cross the socket in the clear — the daemon needs the live +/// value to inject into a child's environment — so this type cannot prevent +/// exposure the way [`crate::secrets::Secret`] does for in-process storage. +/// What it does guarantee: `Debug` never prints it (so a stray `{:?}` of a +/// `RegisterPayload` or a log line built from one can't leak it), and the +/// byte buffer zeroizes on drop. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WireSecret { + /// The `[secrets.