From bf53d92d4913f44857a34c692cdbd496d2b7bd54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 20 Mar 2026 18:40:08 +0100 Subject: [PATCH] Initial commit --- Cargo.lock | 570 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 19 ++ src/agents.rs | 21 ++ src/blacklist.rs | 267 ++++++++++++++++++++ src/errors.rs | 63 +++++ src/lib.rs | 41 ++++ src/main.rs | 131 ++++++++++ src/preflight.rs | 38 +++ src/sandbox.rs | 132 ++++++++++ tests/integration.rs | 220 +++++++++++++++++ 10 files changed, 1502 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/agents.rs create mode 100644 src/blacklist.rs create mode 100644 src/errors.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/preflight.rs create mode 100644 src/sandbox.rs create mode 100644 tests/integration.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5328d96 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,570 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agent-sandbox" +version = "0.1.0" +dependencies = [ + "clap", + "glob", + "tempfile", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..98f6256 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "agent-sandbox" +version = "0.1.0" +edition = "2024" + +[lib] +name = "agent_sandbox" +path = "src/lib.rs" + +[[bin]] +name = "agent-sandbox" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +glob = "0.3" + +[dev-dependencies] +tempfile = "3" diff --git a/src/agents.rs b/src/agents.rs new file mode 100644 index 0000000..170daec --- /dev/null +++ b/src/agents.rs @@ -0,0 +1,21 @@ +use std::env; +use std::path::PathBuf; + +pub fn agent_rw_paths() -> Vec { + let home = match env::var("HOME") { + Ok(h) => PathBuf::from(h), + Err(_) => return vec![], + }; + + let candidates = [ + env::var("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".claude")), + env::var("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home.join(".codex")), + home.join(".pi"), + ]; + + candidates.into_iter().filter(|p| p.is_dir()).collect() +} diff --git a/src/blacklist.rs b/src/blacklist.rs new file mode 100644 index 0000000..a49d730 --- /dev/null +++ b/src/blacklist.rs @@ -0,0 +1,267 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::SandboxError; + +pub struct PathContext { + pub home: String, + pub run_user: String, +} + +pub struct BlacklistOverlays { + pub tmpfs_dirs: Vec, + pub null_bind_files: Vec, +} + +pub fn resolve_overlays(ctx: &PathContext) -> Result { + let mut tmpfs_dirs = Vec::new(); + let mut null_bind_files = Vec::new(); + + for raw in SENSITIVE_PATHS { + let expanded = expand_path(raw, ctx); + for path in expand_glob(&expanded)? { + match classify_path(&path) { + PathKind::Dir => tmpfs_dirs.push(path), + PathKind::File => { + if !is_under_tmpfs_dir(&path, &tmpfs_dirs) { + null_bind_files.push(path); + } + } + PathKind::Missing => {} + } + } + } + + Ok(BlacklistOverlays { + tmpfs_dirs, + null_bind_files, + }) +} + +pub fn resolve_path_context() -> Result { + let home = std::env::var("HOME").map_err(|_| SandboxError::HomeNotSet)?; + let run_user = std::env::var("XDG_RUNTIME_DIR") + .unwrap_or_else(|_| resolve_run_user_from_proc().unwrap_or_else(|| "/run/user/0".into())); + Ok(PathContext { home, run_user }) +} + +enum PathKind { + Dir, + File, + Missing, +} + +fn classify_path(path: &Path) -> PathKind { + match fs::symlink_metadata(path) { + Ok(m) if m.is_dir() => PathKind::Dir, + Ok(_) => PathKind::File, + Err(_) => PathKind::Missing, + } +} + +fn expand_path(raw: &str, ctx: &PathContext) -> String { + let s = raw + .replace("${HOME}", &ctx.home) + .replace("${RUNUSER}", &ctx.run_user); + let s = if let Some(rest) = s.strip_prefix('~') { + format!("{}{rest}", ctx.home) + } else { + s + }; + assert!( + !s.contains("${"), + "unexpanded variable in SENSITIVE_PATHS entry: {raw}" + ); + s +} + +fn expand_glob(pattern: &str) -> Result, SandboxError> { + let entries = glob::glob(pattern)?; + Ok(entries.filter_map(|r| r.ok()).collect()) +} + +fn is_under_tmpfs_dir(path: &Path, tmpfs_dirs: &[PathBuf]) -> bool { + tmpfs_dirs.iter().any(|dir| path.starts_with(dir)) +} + +fn resolve_run_user_from_proc() -> Option { + let status = fs::read_to_string("/proc/self/status").ok()?; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("Uid:") { + let uid = rest.split_whitespace().next()?; + return Some(format!("/run/user/{uid}")); + } + } + None +} + +// --------------------------------------------------------------------------- +// Curated sensitive paths from firejail disable-common.inc + disable-programs.inc. +// Goal: protect secrets, credentials, and session tokens from agentic access. +// --------------------------------------------------------------------------- + +const SENSITIVE_PATHS: &[&str] = &[ + // -- history files (can leak passwords/tokens typed on command line) -- + "${HOME}/.*_history", + "${HOME}/.*_history_*", + "${HOME}/.histfile", + "${HOME}/.history", + "${HOME}/.python-history", + "${HOME}/.pythonhist", + "${HOME}/.viminfo", + "${HOME}/.lesshst", + // -- clipboard managers (may contain copied passwords) -- + "${HOME}/.cache/greenclip*", + "${HOME}/.kde/share/apps/klipper", + "${HOME}/.kde4/share/apps/klipper", + "${HOME}/.local/share/klipper", + "/tmp/clipmenu*", + // -- SSH and remote access -- + "${HOME}/.ssh", + "${HOME}/.rhosts", + "${HOME}/.shosts", + "/etc/hosts.equiv", + "/etc/ssh", + "/etc/ssh/*", + // -- GPG -- + "${HOME}/.gnupg", + // -- git credentials -- + "${HOME}/.git-credentials", + "${HOME}/.git-credential-cache", + "${HOME}/.config/hub", + "${HOME}/.config/gh", + // -- general credentials and secrets -- + "${HOME}/.netrc", + "${HOME}/.cargo/credentials", + "${HOME}/.cargo/credentials.toml", + "${HOME}/.fetchmailrc", + "${HOME}/.msmtprc", + "${HOME}/.smbcredentials", + "${HOME}/.davfs2/secrets", + "${HOME}/.config/msmtp", + "${HOME}/.config/keybase", + "${HOME}/.minisign", + "${HOME}/.caff", + "${HOME}/.password-store", + // -- cloud provider credentials -- + "${HOME}/.aws", + "${HOME}/.boto", + "${HOME}/.config/gcloud", + "${HOME}/.kube", + "${HOME}/.passwd-s3fs", + "${HOME}/.s3cmd", + "/etc/boto.cfg", + // -- keyrings and wallets -- + "${HOME}/.gnome2/keyrings", + "${HOME}/.local/share/keyrings", + "${HOME}/.local/share/kwalletd", + "${HOME}/.kde/share/apps/kwallet", + "${HOME}/.kde4/share/apps/kwallet", + // -- certificates and PKI -- + "${HOME}/.pki", + "${HOME}/.cert", + "${HOME}/.local/share/pki", + "${HOME}/.local/share/plasma-vault", + "${HOME}/.vaults", + // -- KeePass databases -- + "${HOME}/*.kdb", + "${HOME}/*.kdbx", + // -- encryption -- + "${HOME}/.ecryptfs", + "${HOME}/.fscrypt", + "${HOME}/.Private", + "${HOME}/Private", + "/.fscrypt", + "/home/.ecryptfs", + "/home/.fscrypt", + "/crypto_keyfile.bin", + // -- system auth files -- + "/etc/shadow", + "/etc/shadow+", + "/etc/shadow-", + "/etc/gshadow", + "/etc/gshadow+", + "/etc/gshadow-", + "/etc/passwd+", + "/etc/passwd-", + "/etc/group+", + "/etc/group-", + "/etc/sudo*.conf", + "/etc/sudoers*", + "/etc/doas.conf", + "/etc/davfs2/secrets", + "/etc/msmtprc", + // -- session directory and sockets (lateral movement vectors) -- + "/tmp/ssh-*", + "/tmp/tmux-*", + "${RUNUSER}", + "/var/run/docker.sock", + // -- mail (sensitive content) -- + "${HOME}/.Mail", + "${HOME}/.mail", + "${HOME}/Mail", + "${HOME}/mail", + "${HOME}/postponed", + "${HOME}/sent", + "${HOME}/.mutt", + "${HOME}/.muttrc", + // -- password managers -- + "${HOME}/.keepass", + "${HOME}/.keepassx", + "${HOME}/.keepassxc", + "${HOME}/.config/KeePass", + "${HOME}/.config/KeePassXCrc", + "${HOME}/.config/keepassxc", + "${HOME}/.cache/keepassxc", + "${HOME}/.local/share/KeePass", + "${HOME}/.config/1Password", + "${HOME}/.config/Bitwarden", + "${HOME}/.config/Enpass", + "${HOME}/.cache/Enpass", + "${HOME}/.local/share/Enpass", + "${HOME}/.lastpass", + "${HOME}/.config/Authenticator", + "${HOME}/.cache/Authenticator", + // -- browser profiles (saved passwords, cookies, session tokens) -- + "${HOME}/.mozilla", + "${HOME}/.cache/mozilla", + "${HOME}/.config/mozilla", + "${HOME}/.config/google-chrome", + "${HOME}/.cache/google-chrome", + "${HOME}/.config/chromium", + "${HOME}/.cache/chromium", + "${HOME}/.config/BraveSoftware", + "${HOME}/.cache/BraveSoftware", + "${HOME}/.config/microsoft-edge", + "${HOME}/.cache/microsoft-edge", + "${HOME}/.config/vivaldi", + "${HOME}/.cache/vivaldi", + "${HOME}/.config/opera", + "${HOME}/.cache/opera", + "${HOME}/.librewolf", + "${HOME}/.cache/librewolf", + "${HOME}/.config/qutebrowser", + "${HOME}/.cache/qutebrowser", + "${HOME}/.local/opt/tor-browser", + "${HOME}/.tor-browser*", + "${HOME}/.cache/torbrowser", + "${HOME}/.config/torbrowser", + // -- cryptocurrency wallets -- + "${HOME}/.*coin", + "${HOME}/.bitcoin", + "${HOME}/.electrum*", + "${HOME}/.ethereum", + "${HOME}/Monero/wallets", + "${HOME}/wallet.dat", + // -- nyx (tor controller) -- + "${HOME}/.nyx", + // -- D-Bus sockets (can execute commands via systemd) -- + "/run/dbus", + "/var/run/dbus", + // -- X11 / Wayland sockets (keystroke injection, screen capture) -- + "/tmp/.X11-unix", + "/tmp/.ICE-unix", + "/tmp/.XIM-unix", + "${RUNUSER}/wayland-*", + "${RUNUSER}/X11-display", +]; diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..bf6e7a4 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,63 @@ +use std::path::PathBuf; + +#[derive(Debug)] +pub enum SandboxError { + HomeNotSet, + BwrapNotFound, + CommandNotFound(PathBuf), + CommandNotExecutable(PathBuf), + RwPathMissing(PathBuf), + RoPathMissing(PathBuf), + ChdirMissing(PathBuf), + CurrentDirUnavailable(std::io::Error), + GlobPattern(glob::PatternError), + Io(std::io::Error), +} + +impl std::fmt::Display for SandboxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::HomeNotSet => write!( + f, + "$HOME is not set; cannot determine which paths to protect" + ), + Self::BwrapNotFound => write!( + f, + "bwrap not found; install bubblewrap (e.g. `apt install bubblewrap` or `pacman -S bubblewrap`)" + ), + Self::CommandNotFound(p) => write!(f, "command not found: {}", p.display()), + Self::CommandNotExecutable(p) => { + write!(f, "command is not executable: {}", p.display()) + } + Self::RwPathMissing(p) => write!(f, "--rw path does not exist: {}", p.display()), + Self::RoPathMissing(p) => write!(f, "--ro path does not exist: {}", p.display()), + Self::ChdirMissing(p) => write!(f, "--chdir path does not exist: {}", p.display()), + Self::CurrentDirUnavailable(e) => write!(f, "cannot determine current directory: {e}"), + Self::GlobPattern(e) => write!(f, "invalid glob pattern: {e}"), + Self::Io(e) => write!(f, "I/O error: {e}"), + } + } +} + +impl std::error::Error for SandboxError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::CurrentDirUnavailable(e) => Some(e), + Self::GlobPattern(e) => Some(e), + Self::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for SandboxError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl From for SandboxError { + fn from(e: glob::PatternError) -> Self { + Self::GlobPattern(e) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a86b186 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,41 @@ +mod agents; +mod blacklist; +mod errors; +mod preflight; +mod sandbox; + +pub use errors::SandboxError; + +use std::ffi::OsString; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; + +pub enum SandboxMode { + Blacklist, + Whitelist, +} + +pub struct SandboxConfig { + pub mode: SandboxMode, + pub hardened: bool, + pub no_net: bool, + pub extra_rw: Vec, + pub extra_ro: Vec, + pub command: PathBuf, + pub command_args: Vec, + pub chdir: PathBuf, + pub dry_run: bool, +} + +pub fn run(config: SandboxConfig) -> Result<(), SandboxError> { + preflight::check(&config)?; + + let mut cmd = sandbox::build_command(&config)?; + + if config.dry_run { + println!("{:?}", cmd); + return Ok(()); + } + + Err(SandboxError::Io(cmd.exec())) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b6fae47 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,131 @@ +use std::ffi::{OsStr, OsString}; +use std::path::PathBuf; +use std::process; + +use clap::Parser; + +use agent_sandbox::{SandboxConfig, SandboxMode}; + +#[derive(Parser, Debug)] +#[command( + name = "agent-sandbox", + version, + about = "Sandbox agentic coding assistants with bubblewrap" +)] +struct Args { + /// Blacklist mode: bind / read-only, overlay sensitive paths (default) + #[arg(long, conflicts_with = "whitelist")] + blacklist: bool, + + /// Whitelist mode: only explicitly listed minimal paths visible + #[arg(long)] + whitelist: bool, + + /// Harden: unshare IPC, PID, UTS; private /tmp, /dev, /run + #[arg(long)] + hardened: bool, + + /// Unshare the network namespace + #[arg(long)] + no_net: bool, + + /// Bind an extra path read-write (repeatable) + #[arg(long = "rw", value_name = "PATH", action = clap::ArgAction::Append)] + extra_rw: Vec, + + /// Bind an extra path read-only (repeatable) + #[arg(long = "ro", value_name = "PATH", action = clap::ArgAction::Append)] + extra_ro: Vec, + + /// Print the bwrap command without executing + #[arg(long)] + dry_run: bool, + + /// Working directory inside the sandbox (default: current directory) + #[arg(long, value_name = "PATH")] + chdir: Option, + + /// Command and arguments to run inside the sandbox + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command_and_args: Vec, +} + +fn main() { + let args = Args::parse(); + + let (command, command_args) = resolve_command(args.command_and_args); + let command = assert_binary_exists(&command); + let chdir = assert_chdir(args.chdir); + + let mode = if args.whitelist { + SandboxMode::Whitelist + } else { + SandboxMode::Blacklist + }; + + let config = SandboxConfig { + mode, + hardened: args.hardened, + no_net: args.no_net, + extra_rw: args.extra_rw, + extra_ro: args.extra_ro, + command, + command_args, + chdir, + dry_run: args.dry_run, + }; + + if let Err(e) = agent_sandbox::run(config) { + eprintln!("error: {e}"); + process::exit(1); + } +} + +fn resolve_command(mut positional: Vec) -> (OsString, Vec) { + if !positional.is_empty() { + let cmd = positional.remove(0); + return (cmd, positional); + } + if let Ok(cmd) = std::env::var("SANDBOX_CMD") { + return (OsString::from(cmd), vec![]); + } + ( + OsString::from("claude"), + vec![OsString::from("--dangerously-skip-permissions")], + ) +} + +fn assert_binary_exists(name: &OsStr) -> PathBuf { + resolve_binary(name).unwrap_or_else(|| { + eprintln!("error: command not found: {}", name.to_string_lossy()); + process::exit(1); + }) +} + +fn assert_chdir(explicit: Option) -> PathBuf { + if let Some(p) = explicit { + return p; + } + match std::env::current_dir() { + Ok(p) => p, + Err(e) => { + eprintln!( + "error: {}", + agent_sandbox::SandboxError::CurrentDirUnavailable(e) + ); + process::exit(1); + } + } +} + +fn resolve_binary(name: &OsStr) -> Option { + let path = PathBuf::from(name); + if path.is_absolute() || path.components().count() > 1 { + return path.is_file().then_some(path); + } + std::env::var_os("PATH").and_then(|path_var| { + std::env::split_paths(&path_var) + .map(|dir| dir.join(name)) + .find(|p| p.is_file()) + }) +} diff --git a/src/preflight.rs b/src/preflight.rs new file mode 100644 index 0000000..6600d1d --- /dev/null +++ b/src/preflight.rs @@ -0,0 +1,38 @@ +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +use crate::SandboxConfig; +use crate::errors::SandboxError; + +pub fn check(config: &SandboxConfig) -> Result<(), SandboxError> { + check_bwrap()?; + check_command(config)?; + check_chdir(config)?; + Ok(()) +} + +fn check_chdir(config: &SandboxConfig) -> Result<(), SandboxError> { + if !config.chdir.is_dir() { + return Err(SandboxError::ChdirMissing(config.chdir.clone())); + } + Ok(()) +} + +fn check_bwrap() -> Result<(), SandboxError> { + Command::new("bwrap") + .arg("--version") + .output() + .map_err(|_| SandboxError::BwrapNotFound)?; + Ok(()) +} + +fn check_command(config: &SandboxConfig) -> Result<(), SandboxError> { + if !config.command.is_file() { + return Err(SandboxError::CommandNotFound(config.command.clone())); + } + let metadata = std::fs::metadata(&config.command)?; + if metadata.permissions().mode() & 0o111 == 0 { + return Err(SandboxError::CommandNotExecutable(config.command.clone())); + } + Ok(()) +} diff --git a/src/sandbox.rs b/src/sandbox.rs new file mode 100644 index 0000000..130cb0e --- /dev/null +++ b/src/sandbox.rs @@ -0,0 +1,132 @@ +use std::path::Path; +use std::process::Command; + +use crate::agents; +use crate::blacklist; +use crate::{SandboxConfig, SandboxError, SandboxMode}; + +pub fn build_command(config: &SandboxConfig) -> Result { + let mut cmd = Command::new("bwrap"); + let hardened = config.hardened || matches!(config.mode, SandboxMode::Whitelist); + + if hardened { + cmd.args(["--unshare-ipc", "--unshare-pid", "--unshare-uts"]); + cmd.args(["--hostname", "sandbox"]); + } + if config.no_net { + cmd.arg("--unshare-net"); + } + + match config.mode { + SandboxMode::Blacklist => add_blacklist_mode(&mut cmd)?, + SandboxMode::Whitelist => add_whitelist_mode(&mut cmd)?, + } + + if hardened { + cmd.args(["--tmpfs", "/tmp"]); + cmd.args(["--dev", "/dev"]); + cmd.args(["--tmpfs", "/run"]); + cmd.args(["--proc", "/proc"]); + } + + for path in agents::agent_rw_paths() { + cmd.arg("--bind").arg(&path).arg(&path); + } + + add_rw_bind(&mut cmd, &config.chdir)?; + for path in &config.extra_rw { + add_rw_bind(&mut cmd, path)?; + } + for path in &config.extra_ro { + add_ro_bind(&mut cmd, path)?; + } + + cmd.arg("--die-with-parent"); + cmd.arg("--chdir").arg(&config.chdir); + + cmd.arg("--") + .arg(&config.command) + .args(&config.command_args); + + Ok(cmd) +} + +fn add_blacklist_mode(cmd: &mut Command) -> Result<(), SandboxError> { + let ctx = blacklist::resolve_path_context()?; + cmd.args(["--ro-bind", "/", "/"]); + + let overlays = blacklist::resolve_overlays(&ctx)?; + for dir in &overlays.tmpfs_dirs { + cmd.arg("--tmpfs").arg(dir); + } + for file in &overlays.null_bind_files { + cmd.arg("--ro-bind").arg("/dev/null").arg(file); + } + Ok(()) +} + +fn add_whitelist_mode(cmd: &mut Command) -> Result<(), SandboxError> { + let home = std::env::var("HOME").map_err(|_| SandboxError::HomeNotSet)?; + + cmd.args(["--ro-bind", "/usr", "/usr"]); + for path in ["/lib", "/lib64", "/lib32", "/bin", "/sbin"] { + cmd.args(["--ro-bind-try", path, path]); + } + + for path in [ + "/etc/ld.so.cache", + "/etc/ld.so.conf", + "/etc/ld.so.conf.d", + "/etc/alternatives", + ] { + cmd.args(["--ro-bind-try", path, path]); + } + + cmd.args(["--ro-bind", "/etc/ssl", "/etc/ssl"]); + cmd.args([ + "--ro-bind-try", + "/etc/ca-certificates", + "/etc/ca-certificates", + ]); + cmd.args(["--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf"]); + cmd.args(["--ro-bind", "/etc/nsswitch.conf", "/etc/nsswitch.conf"]); + cmd.args(["--ro-bind", "/etc/passwd", "/etc/passwd"]); + cmd.args(["--ro-bind", "/etc/group", "/etc/group"]); + + for path in [ + "/etc/hosts", + "/etc/gai.conf", + "/etc/services", + "/etc/protocols", + ] { + cmd.args(["--ro-bind-try", path, path]); + } + + for path in ["/etc/hostname", "/etc/localtime", "/etc/machine-id"] { + cmd.args(["--ro-bind-try", path, path]); + } + + let local_bin = format!("{home}/.local/bin"); + cmd.arg("--ro-bind-try").arg(&local_bin).arg(&local_bin); + + let cache_dir = format!("{home}/.cache"); + cmd.arg("--tmpfs").arg(&cache_dir); + + Ok(()) +} + +fn add_rw_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { + if !path.exists() { + return Err(SandboxError::RwPathMissing(path.to_path_buf())); + } + cmd.arg("--bind").arg(path).arg(path); + Ok(()) +} + +fn add_ro_bind(cmd: &mut Command, path: &Path) -> Result<(), SandboxError> { + if !path.exists() { + return Err(SandboxError::RoPathMissing(path.to_path_buf())); + } + cmd.arg("--ro-bind").arg(path).arg(path); + Ok(()) +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..774fab3 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,220 @@ +use std::fs; +use std::process::Command; + +use tempfile::TempDir; + +fn sandbox(extra_args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_agent-sandbox")); + cmd.args(extra_args); + cmd +} + +#[test] +fn cwd_is_writable() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "touch ./sandbox_canary && echo ok"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("ok"), + "expected 'ok' in stdout, got: {stdout}" + ); +} + +#[test] +fn host_fs_is_readonly() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "touch /etc/pwned 2>&1 || echo readonly"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("readonly"), + "expected 'readonly' in stdout, got: {stdout}" + ); + assert!(!std::path::Path::new("/etc/pwned").exists()); +} + +#[test] +fn ssh_dir_is_hidden() { + let output = sandbox(&[]) + .args(["--", "bash", "-c", "ls ~/.ssh 2>/dev/null | wc -l"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!(stdout, "0", "expected empty ~/.ssh, got {stdout} entries"); +} + +#[test] +fn no_net_blocks_network() { + let output = sandbox(&["--no-net"]) + .args([ + "--", + "bash", + "-c", + "curl -s --max-time 2 http://1.1.1.1 2>&1; echo $?", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + !stdout.trim().ends_with("0"), + "expected curl to fail, got: {stdout}" + ); +} + +#[test] +fn hardened_pid_namespace() { + let output = sandbox(&["--hardened"]) + .args(["--", "bash", "-c", "ls /proc | grep -cE '^[0-9]+$'"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let count: u32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(999); + assert!( + count < 10, + "expected isolated PID namespace with few PIDs, got {count}" + ); +} + +#[test] +fn whitelist_hides_home_contents() { + let output = sandbox(&["--whitelist"]) + .args(["--", "bash", "-c", "ls ~/Documents 2>&1 || echo hidden"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hidden"), + "expected ~/Documents to be hidden, got: {stdout}" + ); +} + +#[test] +fn extra_ro_mount() { + let dir = TempDir::new().expect("failed to create temp dir"); + fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--ro", dir_str]) + .args([ + "--", + "bash", + "-c", + &format!("cat {dir_str}/hello.txt && touch {dir_str}/new 2>&1 || echo readonly"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("hi"), + "expected file content 'hi', got: {stdout}" + ); + assert!( + stdout.contains("readonly"), + "expected ro mount to block writes, got: {stdout}" + ); +} + +#[test] +fn extra_rw_mount() { + let dir = TempDir::new().expect("failed to create temp dir"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--rw", dir_str]) + .args([ + "--", + "bash", + "-c", + &format!("touch {dir_str}/canary && echo ok"), + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("ok"), "expected 'ok', got: {stdout}"); + assert!( + dir.path().join("canary").exists(), + "canary file should exist on host after rw mount" + ); +} + +#[test] +fn chdir_override() { + let dir = TempDir::new().expect("failed to create temp dir"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--chdir", dir_str]) + .args(["--", "bash", "-c", "pwd"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, dir_str, + "expected cwd to be {dir_str}, got: {stdout}" + ); +} + +#[test] +fn chdir_under_hardened_tmp() { + let dir = TempDir::new().expect("failed to create temp dir"); + let dir_str = dir.path().to_str().unwrap(); + + let output = sandbox(&["--hardened", "--chdir", dir_str]) + .args(["--", "bash", "-c", "pwd && touch ./ok && echo done"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("done"), + "expected chdir under /tmp to work with --hardened, got: {stdout}" + ); +} + +#[test] +fn dry_run_prints_and_exits() { + let output = sandbox(&["--dry-run"]) + .args(["--", "bash", "-c", "exit 42"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("bwrap"), + "expected bwrap command in dry-run output, got: {stdout}" + ); + assert!( + output.status.success(), + "dry-run should exit 0, not 42 from the inner command" + ); +} + +#[test] +fn rw_missing_path_errors() { + let output = sandbox(&["--rw", "/nonexistent/xyz"]) + .args(["--", "true"]) + .output() + .expect("agent-sandbox binary failed to execute"); + + assert!( + !output.status.success(), + "expected non-zero exit for missing --rw path" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("/nonexistent/xyz"), + "expected path in error message, got: {stderr}" + ); +}