diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 4331807..29b72fb 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -34,10 +34,11 @@ pub struct Jail { /// must write to without it landing inside the root (e.g. a per-call /// output-capture directory). Grant the narrowest directory that works. pub read_write: Vec, - /// Paths the child may neither read nor write, even inside the root or a - /// grant. Only a backend that [`carves_denials`](JailBackend::carves_denials) - /// can subtract them from a grant; everywhere else they are unreachable - /// already because nothing grants them. + /// Paths the child may neither read nor write. A backend that + /// [`carves_denials`](JailBackend::carves_denials) subtracts them from + /// grants; allow-list backends deny paths outside grants by default. The + /// native module rejects overlaps that an allow-list backend cannot enforce. + /// Missing paths are retained so backend rules can cover later creation. pub deny_read: Vec, /// Optional hard CPU, memory, process and writable-disk limits. /// @@ -144,6 +145,43 @@ impl Jail { *p = c; } } + self.deny_read = self + .deny_read + .iter() + .map(|path| crate::registry::canonicalize_missing(path)) + .collect::>>()?; + self.deny_read.sort(); + self.deny_read.dedup(); + Ok(()) + } + + /// Refuse denials that a non-carving backend cannot preserve against its + /// explicit and implicit grants after path canonicalization. + pub(crate) fn validate_denials( + &self, + carves_denials: bool, + implicit_grants: &[PathBuf], + ) -> std::io::Result<()> { + if carves_denials || self.deny_read.is_empty() { + return Ok(()); + } + let implicit_grants = implicit_grants + .iter() + .map(|path| crate::registry::canonicalize_missing(path)) + .collect::>>()?; + for denied in &self.deny_read { + if std::iter::once(&self.root) + .chain(&self.read_only) + .chain(&self.read_write) + .chain(&implicit_grants) + .any(|granted| denied.starts_with(granted) || granted.starts_with(denied)) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "denied path overlaps an allow-list or implicit grant", + )); + } + } Ok(()) } @@ -167,6 +205,16 @@ impl Jail { /// `AppContainer` requires custom `CreateProcess` flags that `std`'s /// `Command::spawn` does not expose. pub trait JailBackend: Send + Sync { + /// Paths the backend implicitly grants in addition to explicit jail roots. + /// + /// Callers enforcing hard denials use these paths to detect overlaps that + /// the backend's allow rules would otherwise override. Backends should + /// report every recursively granted baseline path; the default means the + /// backend has no implicit filesystem grants. + fn implicit_grants(&self) -> Vec { + Vec::new() + } + /// Whether [`Jail::deny_read`] is enforced even inside the root or a grant. /// Allow-list backends (Landlock, `AppContainer`) cannot subtract from a /// grant, so they keep the default `false`. diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 5f6a4d2..bf786dc 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -110,3 +110,22 @@ fn canonicalize_keeps_missing_read_write_as_is() { j.canonicalize().unwrap(); assert_eq!(j.read_write, vec![PathBuf::from("/this/never/existed")]); } + +#[cfg(unix)] +#[test] +fn canonicalize_deduplicates_denials_that_resolve_through_symlink_aliases() -> std::io::Result<()> { + use std::os::unix::fs::symlink; + let root = tempfile::tempdir()?; + let store = tempfile::tempdir()?; + let credential = store.path().join("credential"); + std::fs::write(&credential, b"secret")?; + let alias = root.path().join("credential-alias"); + symlink(&credential, &alias)?; + let mut jail = Jail::new(root.path(), "alias-denial"); + jail.deny_read = vec![alias, credential.clone()]; + + jail.canonicalize()?; + + assert_eq!(jail.deny_read, vec![credential.canonicalize()?]); + Ok(()) +} diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index e57c2cd..8bdf9b1 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -101,6 +101,7 @@ pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result { jail.canonicalize()?; let backend = default_backend(); require_resources(backend.as_ref(), &jail)?; + jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; backend.spawn(&jail, cmd) } @@ -119,6 +120,7 @@ pub fn spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> std:: let mut jail = jail.clone(); jail.canonicalize()?; require_resources(backend, &jail)?; + jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; backend.spawn(&jail, cmd) } @@ -134,6 +136,7 @@ pub fn spawn_captured_with( let mut jail = jail.clone(); jail.canonicalize()?; require_resources(backend, &jail)?; + jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; backend.spawn_captured(&jail, cmd) } diff --git a/crates/tinybox-jail/src/linux.rs b/crates/tinybox-jail/src/linux.rs index 1aecfdf..9123c57 100644 --- a/crates/tinybox-jail/src/linux.rs +++ b/crates/tinybox-jail/src/linux.rs @@ -92,6 +92,14 @@ impl LandlockBackend { } impl JailBackend for LandlockBackend { + fn implicit_grants(&self) -> Vec { + SYSTEM_READ_PATHS + .iter() + .chain(DEVICE_PATHS) + .map(std::path::PathBuf::from) + .collect() + } + fn name(&self) -> &'static str { LANDLOCK_BACKEND_NAME } @@ -120,7 +128,10 @@ impl JailBackend for LandlockBackend { self.constraint_support() .require(self.name(), &requested) .map_err(|error| io::Error::new(io::ErrorKind::Unsupported, error))?; - imp::spawn(jail, cmd) + let mut jail = jail.clone(); + jail.canonicalize()?; + jail.validate_denials(false, &self.implicit_grants())?; + imp::spawn(&jail, cmd) } } diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index 8730130..fd1e02e 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -231,6 +231,68 @@ fn baseline_lets_the_resolver_config_be_read_through_its_symlink() -> io::Result Ok(()) } +#[test] +fn raw_landlock_spawn_refuses_a_deny_root_that_overlaps_implicit_baseline_access() -> io::Result<()> +{ + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let backend = LandlockBackend::new(); + let baseline = SYSTEM_READ_PATHS + .iter() + .chain(DEVICE_PATHS) + .filter(|path| Path::new(path).exists()) + .collect::>(); + assert!( + !baseline.is_empty(), + "the test host must expose baseline paths" + ); + for path in baseline { + let mut jail = jail_for(root.path()); + jail.deny_read.push(Path::new(path).to_path_buf()); + let result = backend.spawn(&jail, Command::new("/bin/true")); + assert_eq!( + result.err().map(|error| error.kind()), + Some(io::ErrorKind::InvalidInput), + "raw LandlockBackend::spawn must refuse deny root {path} that overlaps an implicit baseline grant" + ); + } + + // `/bin` is commonly a symlink to `/usr/bin`; prospective denials through + // the alias must also conflict with the canonical baseline grant. + let mut aliased = jail_for(root.path()); + aliased + .deny_read + .push(Path::new("/bin/tinybox-future-credential").to_path_buf()); + let result = backend.spawn(&aliased, Command::new("/bin/true")); + assert_eq!( + result.err().map(|error| error.kind()), + Some(io::ErrorKind::InvalidInput) + ); + Ok(()) +} + +#[test] +fn landlock_validation_keeps_future_baseline_roots_protected() -> io::Result<()> { + let root = tempfile::tempdir()?; + let baseline_root = tempfile::tempdir()?; + let future_baseline = baseline_root + .path() + .join("baseline-created-after-preflight"); + let mut jail = jail_for(root.path()); + jail.deny_read + .push(future_baseline.join("future-credential")); + + let result = jail.validate_denials(false, &[future_baseline]); + assert_eq!( + result.err().map(|error| error.kind()), + Some(io::ErrorKind::InvalidInput), + "a prospective baseline grant could appear before Landlock opens its rules" + ); + Ok(()) +} + /// File grants must enforce fully without granting their parent directory. #[test] fn individual_file_grants_preserve_read_only_and_read_write_access() -> io::Result<()> { diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 1713381..39028c5 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -157,6 +157,152 @@ fn facade_refuses_each_zero_resource_limit_even_when_backend_claims_enforcement( Ok(()) } +#[test] +fn facade_refuses_denials_nested_under_backend_implicit_grants() -> std::io::Result<()> { + struct BaselineBackend { + baseline: std::path::PathBuf, + } + + impl JailBackend for BaselineBackend { + fn implicit_grants(&self) -> Vec { + vec![self.baseline.clone()] + } + + fn name(&self) -> &'static str { + "baseline-test" + } + + fn is_available(&self) -> bool { + true + } + + fn spawn(&self, _jail: &Jail, mut cmd: Command) -> std::io::Result { + cmd.spawn() + } + } + + let root = tempfile::tempdir()?; + let baseline_root = tempfile::tempdir()?; + let baseline = baseline_root.path().join("implicit-baseline"); + std::fs::create_dir(&baseline)?; + let marker = root.path().join("executed"); + let mut jail = Jail::new(root.path(), "baseline-denial"); + jail.deny_read.push(baseline.join("future-secret")); + let mut command = if cfg!(windows) { + let mut command = Command::new("cmd"); + command.args(["/C", "echo executed > executed"]); + command + } else { + let mut command = Command::new("sh"); + command.args(["-c", "printf executed > executed"]); + command + }; + command.current_dir(root.path()); + + let error = spawn_with(&BaselineBackend { baseline }, &jail, command).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!( + !marker.exists(), + "backend must not spawn after denial conflict" + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn facade_spawns_without_denials_when_optional_implicit_grant_is_dangling() -> std::io::Result<()> { + struct BaselineBackend { + baseline: std::path::PathBuf, + spawned: std::sync::Arc, + } + + impl JailBackend for BaselineBackend { + fn implicit_grants(&self) -> Vec { + vec![self.baseline.clone()] + } + + fn name(&self) -> &'static str { + "dangling-baseline-test" + } + + fn is_available(&self) -> bool { + true + } + + fn spawn(&self, _jail: &Jail, mut cmd: Command) -> std::io::Result { + self.spawned + .store(true, std::sync::atomic::Ordering::SeqCst); + cmd.spawn() + } + } + + let root = tempfile::tempdir()?; + let baseline_root = tempfile::tempdir()?; + let baseline = baseline_root.path().join("optional-baseline"); + std::os::unix::fs::symlink(baseline_root.path().join("missing-target"), &baseline)?; + let spawned = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let backend = BaselineBackend { + baseline, + spawned: spawned.clone(), + }; + let mut child = spawn_with( + &backend, + &Jail::new(root.path(), "optional-baseline"), + Command::new("true"), + )?; + assert!(spawned.load(std::sync::atomic::Ordering::SeqCst)); + assert!(child.wait()?.success()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn facade_refuses_denials_when_implicit_grant_is_dangling() -> std::io::Result<()> { + struct BaselineBackend { + baseline: std::path::PathBuf, + spawned: std::sync::Arc, + } + + impl JailBackend for BaselineBackend { + fn implicit_grants(&self) -> Vec { + vec![self.baseline.clone()] + } + + fn name(&self) -> &'static str { + "dangling-baseline-test" + } + + fn is_available(&self) -> bool { + true + } + + fn spawn(&self, _jail: &Jail, mut cmd: Command) -> std::io::Result { + self.spawned + .store(true, std::sync::atomic::Ordering::SeqCst); + cmd.spawn() + } + } + + let root = tempfile::tempdir()?; + let baseline_root = tempfile::tempdir()?; + let denial_root = tempfile::tempdir()?; + let baseline = baseline_root.path().join("optional-baseline"); + std::os::unix::fs::symlink(baseline_root.path().join("missing-target"), &baseline)?; + let spawned = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let backend = BaselineBackend { + baseline, + spawned: spawned.clone(), + }; + let mut jail = Jail::new(root.path(), "denial-with-dangling-baseline"); + jail.deny_read + .push(denial_root.path().join("future-denial")); + + let error = spawn_with(&backend, &jail, Command::new("true")).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + assert!(!spawned.load(std::sync::atomic::Ordering::SeqCst)); + Ok(()) +} + #[test] fn default_backend_returns_something() { let b = default_backend(); diff --git a/crates/tinybox-jail/src/registry.rs b/crates/tinybox-jail/src/registry.rs index e0698b8..baf9755 100644 --- a/crates/tinybox-jail/src/registry.rs +++ b/crates/tinybox-jail/src/registry.rs @@ -42,6 +42,7 @@ use super::jail::{Jail, JailBackend}; #[path = "registry_path.rs"] mod path; +pub(crate) use path::canonicalize_missing; #[path = "registry_index_store.rs"] mod index_store; diff --git a/crates/tinybox-jail/src/registry_path.rs b/crates/tinybox-jail/src/registry_path.rs index 5d229e7..7127de0 100644 --- a/crates/tinybox-jail/src/registry_path.rs +++ b/crates/tinybox-jail/src/registry_path.rs @@ -5,7 +5,7 @@ use std::path::{Component, Path, PathBuf}; /// Canonicalize the existing ancestor and append missing normal components. /// Never treat a dangling symlink or an access failure as a missing directory. -pub(super) fn canonicalize_missing(path: &Path) -> io::Result { +pub(crate) fn canonicalize_missing(path: &Path) -> io::Result { if path .components() .any(|part| matches!(part, Component::ParentDir)) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 3485d4d..22664ee 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -252,11 +252,14 @@ partial constraints; `Declared` requires an explicit host decision using `JailStatus`. Neither mode admits the no-op or unavailable backend. A caller requiring the process-isolation security floor sets `require_process_isolation`. The host retains approval policy and chooses the credential paths; it sends them -as `deny_read` (1.20), which the module canonicalizes (dropping paths that do not -exist) and refuses when a denial covers the root or a writable grant, or lies -inside the root or a grant on a backend that cannot subtract from grants. -Seatbelt denies them last in its profile; Landlock and `AppContainer` never grant -them. +as `deny_read` (1.20). The module retains prospective paths and resolves them +against current filesystem aliases both at preflight and immediately before +spawn. It refuses a denial covering the root or a writable grant. Seatbelt can +carve a nested denial out of a writable grant; allow-list backends refuse any +overlap they cannot subtract. Landlock reports its implicit system and device +baseline grants so credential denials cannot be overridden by those grants; +direct Landlock backend calls repeat this check at spawn. Paths outside every +grant remain default-denied on allow-list backends. The jail owns a separate native collector for each execution. Output replay, Cancel, ReleaseOutput, Close and Shutdown use the same retained cleanup barriers diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 1d70121..89e4d4e 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -7,6 +7,7 @@ use tinybox_bus::{JailEnforcement, NativeJailConfig}; use tinybox_jail::{Jail, JailBackend}; use tinybus::{Error, Result}; +/// Validate native jail policy and resolve every path before process admission. pub(super) fn prepare( root: &Path, config: &NativeJailConfig, @@ -55,25 +56,33 @@ pub(super) fn prepare( return Err(invalid()); } // A denial over the root or a writable grant would leave the command no - // workspace; that is a host error, not a weaker jail. + // workspace; a denial nested inside a writable grant is safe only for a + // backend that applies denials after its write allows. if jail.deny_read.iter().any(|denied| { - std::iter::once(&jail.root) - .chain(&jail.read_write) - .any(|granted| granted.starts_with(denied)) + jail.root.starts_with(denied) + || jail.read_write.iter().any(|granted| { + granted.starts_with(denied) + || (!backend.carves_denials() && denied.starts_with(granted)) + }) }) { return Err(invalid()); } - // Inside the root or a grant, only a backend that subtracts from grants - // can keep the denial; outside them nothing grants the path anyway. - if !backend.carves_denials() - && jail.deny_read.iter().any(|denied| { + if !backend.carves_denials() { + let mut implicit_grants = Vec::new(); + for grant in backend.implicit_grants() { + match canonicalize_prospective(&grant) { + Ok(canonical) => implicit_grants.push(canonical), + Err(_) => return Err(unavailable()), + } + } + if jail.deny_read.iter().any(|denied| { std::iter::once(&jail.root) .chain(&jail.read_only) - .chain(&jail.read_write) - .any(|granted| denied.starts_with(granted)) - }) - { - return Err(unavailable()); + .chain(&implicit_grants) + .any(|granted| overlaps(denied, granted)) + }) { + return Err(unavailable()); + } } backend .require_resources(&jail) @@ -93,6 +102,7 @@ pub(super) fn prepare( } Ok(jail) } +/// Validate and canonicalize each existing absolute grant path. fn grants(paths: &[String]) -> Result> { paths .iter() @@ -111,9 +121,8 @@ fn grants(paths: &[String]) -> Result> { }) .collect() } -/// Denied paths, validated like grants; a path that does not exist cannot be -/// read and is dropped, an existing one is canonicalized so the backend matches -/// the real location behind a symlink. +/// Keep lexical prospective paths as well as canonical aliases for existing +/// ancestors so a later-created credential store remains denied. fn denials(paths: &[String]) -> Result> { let mut denied = Vec::new(); for value in paths { @@ -127,14 +136,66 @@ fn denials(paths: &[String]) -> Result> { { return Err(invalid()); } - match path.canonicalize() { - Ok(path) => denied.push(path), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + denied.push(path.to_path_buf()); + match canonicalize_prospective(path) { + Ok(canonical) if canonical != path => denied.push(canonical), + Ok(_) => {} Err(_) => return Err(invalid()), } } + denied.sort(); + denied.dedup(); Ok(denied) } + +/// Resolve a denial through existing aliases while preserving missing suffixes. +fn canonicalize_prospective(path: &Path) -> std::io::Result { + match path.canonicalize() { + Ok(canonical) => return Ok(canonical), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let mut ancestor = path; + let mut suffix = Vec::new(); + loop { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + // Resolve existing aliases for prospective descendants, but + // refuse dangling links and loops that have no stable target. + let mut canonical = ancestor.canonicalize()?; + for component in suffix.iter().rev() { + canonical.push(component); + } + return Ok(canonical); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let name = ancestor.file_name().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no existing path ancestor") + })?; + suffix.push(name.to_os_string()); + ancestor = ancestor.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no existing path ancestor") + })?; + match ancestor.canonicalize() { + Ok(mut canonical) => { + for component in suffix.iter().rev() { + canonical.push(component); + } + return Ok(canonical); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } +} + +/// Return whether either path contains the other by path components. +fn overlaps(left: &Path, right: &Path) -> bool { + left.starts_with(right) || right.starts_with(left) +} fn invalid() -> Error { super::resources::failure( tinybox_bus::INVALID_FILE_PATH, @@ -148,12 +209,13 @@ fn unavailable() -> Error { ) } +/// Create an owned collector only after the native jail passes preflight. pub(super) fn collector( root: &Path, config: &NativeJailConfig, command: &tinybox_core::ExecRequest, + backend: Arc, ) -> Result> { - let backend = tinybox_jail::default_backend(); let jail = prepare(root, config, command, backend.as_ref())?; tinybox_host::JailLocalHost::with_backend(jail, backend, tinybox_bus::MAX_OUTPUT_BYTES) .map(Arc::new) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index be3c463..d7b44ab 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -42,6 +42,30 @@ impl JailBackend for Backend { Err(std::io::Error::other("preflight fixture never spawns")) } } + +struct BaselineBackend(Backend, std::path::PathBuf); +impl JailBackend for BaselineBackend { + fn name(&self) -> &'static str { + self.0.name + } + fn is_available(&self) -> bool { + self.0.available + } + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { + self.0.constraint_support() + } + fn implicit_grants(&self) -> Vec { + vec![self.1.clone()] + } + fn spawn( + &self, + jail: &Jail, + cmd: std::process::Command, + ) -> std::io::Result { + self.0.spawn(jail, cmd) + } +} + fn config() -> NativeJailConfig { NativeJailConfig { read_only: vec![], @@ -238,7 +262,7 @@ fn declared_enforcement_rejects_each_unsupported_requested_constraint() // `prepare` refuses every native jail off Unix before reading any path. #[cfg(unix)] #[test] -fn denials_are_canonicalized_and_refused_where_a_backend_cannot_keep_them() +fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() -> std::result::Result<(), Box> { let home = tempfile::tempdir()?; let root = home.path().join("project"); @@ -262,22 +286,37 @@ fn denials_are_canonicalized_and_refused_where_a_backend_cannot_keep_them() path(&token), path(&home.path().join(".aws")), ]; + let canonical_home = home.path().canonicalize()?; + let mut expected = vec![ + secrets.clone(), + token.clone(), + home.path().join(".aws"), + canonical_home.join(".ssh"), + canonical_home.join(".netrc"), + canonical_home.join(".aws"), + ]; + expected.sort(); + expected.dedup(); - // Outside every grant: any backend keeps it; the absent path is dropped. + // Outside every grant: retain absent roots so a later-created credential + // store remains denied by the backend. for name in ["fixture", "seatbelt"] { let jail = prepare(&root, &config, &command, &backend(name))?; - assert_eq!( - jail.deny_read, - vec![secrets.canonicalize()?, token.canonicalize()?] - ); + assert_eq!(jail.deny_read, expected); } // Inside a read grant: only a backend that subtracts from grants keeps it. config.read_only = vec![path(home.path())]; assert!(prepare(&root, &config, &command, &backend("fixture")).is_err()); let jail = prepare(&root, &config, &command, &backend("seatbelt"))?; - assert_eq!(jail.deny_read.len(), 2); + assert_eq!(jail.deny_read, expected); + config.deny_read = vec![path(&home.path().join(".future-credential-store"))]; + assert!(prepare(&root, &config, &command, &backend("fixture")).is_err()); + assert!(prepare(&root, &config, &command, &backend("seatbelt")).is_ok()); config.read_only.clear(); + config.deny_read = vec![path(&root.join(".future-credential-store"))]; + assert!(prepare(&root, &config, &command, &backend("fixture")).is_err()); + assert!(prepare(&root, &config, &command, &backend("seatbelt")).is_ok()); // A denial over the root or a writable grant is refused everywhere. for denied in [path(home.path()), path(&root)] { @@ -289,6 +328,15 @@ fn denials_are_canonicalized_and_refused_where_a_backend_cannot_keep_them() assert!(prepare(&root, &config, &command, &backend("seatbelt")).is_err()); config.read_write.clear(); + // Seatbelt's final deny rule can carve a nested path out of a writable + // grant, but it cannot preserve the grant if the denial contains it. + config.read_write = vec![path(home.path())]; + config.deny_read = vec![path(&secrets)]; + assert!(prepare(&root, &config, &command, &backend("seatbelt")).is_ok()); + config.deny_read = vec![path(home.path())]; + assert!(prepare(&root, &config, &command, &backend("seatbelt")).is_err()); + config.read_write.clear(); + // Malformed denials are refused like malformed grants. for bad in ["relative", "/usr/../etc", "/bad\0path"] { config.deny_read = vec![bad.into()]; @@ -296,3 +344,207 @@ fn denials_are_canonicalized_and_refused_where_a_backend_cannot_keep_them() } Ok(()) } + +#[cfg(unix)] +#[test] +fn allow_list_backend_rejects_denials_over_implicit_baseline_grants() +-> std::result::Result<(), Box> { + let temp = tempfile::tempdir()?; + let root = temp.path().join("workspace"); + let baseline = temp.path().join("implicit-read-root"); + let alias = temp.path().join("implicit-read-alias"); + std::fs::create_dir(&root)?; + std::fs::create_dir(&baseline)?; + std::os::unix::fs::symlink(&baseline, &alias)?; + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.clone()); + let mut config = config(); + config.deny_read = vec![ + baseline + .join("future-credential") + .to_string_lossy() + .into_owned(), + ]; + + let backend = BaselineBackend( + Backend { + available: true, + suitable: false, + name: "allow-list", + full: true, + }, + alias, + ); + + assert!(prepare(&root, &config, &command, &backend).is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn allow_list_preflight_keeps_future_implicit_grants_protected() +-> std::result::Result<(), Box> { + let temp = tempfile::tempdir()?; + let root = temp.path().join("workspace"); + let future_baseline = temp.path().join("baseline-created-before-spawn"); + std::fs::create_dir(&root)?; + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.clone()); + let mut config = config(); + config.deny_read = vec![ + future_baseline + .join("future-credential") + .to_string_lossy() + .into_owned(), + ]; + let backend = BaselineBackend( + Backend { + available: true, + suitable: false, + name: "allow-list", + full: true, + }, + future_baseline, + ); + + assert!(prepare(&root, &config, &command, &backend).is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn prospective_denials_resolve_existing_aliases_and_refuse_dangling_symlinks() +-> std::result::Result<(), Box> { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir()?; + let link = dir.path().join("credential-link"); + symlink(dir.path().join("future-target"), &link)?; + assert!(denials(&[link.to_string_lossy().into_owned()]).is_err()); + + let target = dir.path().join("real-home"); + std::fs::create_dir(&target)?; + let alias = dir.path().join("home-alias"); + symlink(&target, &alias)?; + let prospective = alias.join(".ssh"); + let roots = denials(&[prospective.to_string_lossy().into_owned()])?; + let mut expected = vec![prospective.clone(), target.canonicalize()?.join(".ssh")]; + expected.sort(); + expected.dedup(); + assert!(roots.contains(&prospective)); + assert_eq!(roots, expected); + + let first_loop = dir.path().join("loop-a"); + let second_loop = dir.path().join("loop-b"); + symlink(&second_loop, &first_loop)?; + symlink(&first_loop, &second_loop)?; + assert!(denials(&[first_loop.to_string_lossy().into_owned()]).is_err()); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn collector_builds_a_host_from_a_valid_native_jail() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.path().to_path_buf()); + + let backend: Arc = Arc::new(Backend { + available: true, + suitable: true, + name: "fixture", + full: true, + }); + let _collector = collector(root.path(), &config(), &command, backend)?; + Ok(()) +} + +#[cfg(unix)] +#[test] +fn spawn_rejects_a_prospective_denial_symlinked_into_the_jail() +-> std::result::Result<(), Box> { + use std::os::unix::fs::symlink; + let home = tempfile::tempdir()?; + let root = home.path().join("project"); + std::fs::create_dir(&root)?; + let credential = root.join("credential"); + std::fs::write(&credential, b"secret")?; + let prospective = home.path().join(".credential-store"); + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.clone()); + let backend = Backend { + available: true, + suitable: false, + name: "fixture", + full: true, + }; + let mut native = config(); + native.allow_net = true; + native.allow_subprocess = true; + native.deny_read = vec![path_string(&prospective)]; + let jail = prepare(&root, &native, &command, &backend)?; + + symlink(&credential, &prospective)?; + let error = + tinybox_jail::spawn_captured_with(&backend, &jail, std::process::Command::new("true")) + .err(); + assert_eq!( + error.map(|error| error.kind()), + Some(std::io::ErrorKind::InvalidInput) + ); + Ok(()) +} + +fn path_string(path: &std::path::Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(target_os = "linux")] +#[test] +fn landlock_keeps_a_denied_path_unreadable_when_created_after_preflight() +-> std::result::Result<(), Box> { + let backend = tinybox_jail::LandlockBackend::new(); + if !backend.is_available() { + eprintln!("skipped: Landlock is not supported by this kernel"); + return Ok(()); + } + let home = tempfile::tempdir()?; + let root = home.path().join("project"); + std::fs::create_dir(&root)?; + let credential = home.path().join(".future-store").join("token"); + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.clone()); + let mut native = config(); + native.allow_net = true; + native.allow_subprocess = true; + native.deny_read = vec![ + home.path() + .join(".future-store") + .to_string_lossy() + .into_owned(), + ]; + let mut uncarvable_root_child = native.clone(); + uncarvable_root_child.deny_read = + vec![root.join(".future-store").to_string_lossy().into_owned()]; + assert!(prepare(&root, &uncarvable_root_child, &command, &backend).is_err()); + let jail = prepare(&root, &native, &command, &backend)?; + assert!(jail.deny_read.contains(&home.path().join(".future-store"))); + + std::fs::create_dir(home.path().join(".future-store"))?; + std::fs::write(&credential, "secret")?; + let allowed = root.join("allowed.txt"); + std::fs::write(&allowed, "allowed-read-control\n")?; + let output = tinybox_jail::spawn_captured_with(&backend, &jail, { + let mut shell = std::process::Command::new("/bin/sh"); + shell.arg("-c").arg(format!( + "cat '{}' && cat '{}' >/dev/null", + allowed.display(), + credential.display() + )); + shell + })? + .wait_with_output()?; + assert_eq!(output.stdout, b"allowed-read-control\n"); + assert!(!output.status.success()); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index ee5cdd4..e56b552 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -145,7 +145,10 @@ fn select_collector( ) })?; Collector::Jailed(super::super::native_jail::collector( - root, config, resolved, + root, + config, + resolved, + tinybox_jail::default_backend(), )?) } else { let native = Arc::new(tinybox_host::LimitedLocalHost::new( diff --git a/docs/specs/contract-1-20.md b/docs/specs/contract-1-20.md index 8ac1663..fff4b26 100644 --- a/docs/specs/contract-1-20.md +++ b/docs/specs/contract-1-20.md @@ -17,13 +17,23 @@ at least 20, which `tinybox_bus::is_compatible` already enforces. ## Semantics - Validated like grants (absolute, no `..`, no NUL, bounded length, counted - against `MAX_NATIVE_JAIL_GRANTS`). A path that does not exist is dropped; an - existing one is canonicalized so a symlinked store is denied at its target. + against `MAX_NATIVE_JAIL_GRANTS`). Missing paths are retained so a store + created after preflight remains denied. Existing aliases and missing paths + beneath existing symlinks resolve to canonical targets; denials are resolved + and checked again immediately before spawn. - Refused (`INVALID_FILE_PATH`) when a denial is the root, an ancestor of it, or covers a writable grant: the command would have no workspace. -- Refused as unavailable when a denial lies inside the root or a grant and the - backend cannot carve it out. Outside every grant, allow-list backends - (Landlock, `AppContainer`) already deny the path. +- Refused as unavailable when a denial overlaps an explicit or implicit grant + and the backend cannot carve it out. Landlock's system and device baseline + paths are implicit grants; the backend reports them so native preflight can + reject credential denials that would otherwise be overridden. Outside every + grant, allow-list backends already deny the path. +- Allow-list backends recheck canonicalized denials at spawn and refuse if a + path created or redirected after preflight now overlaps the root, a grant, + or an implicit baseline path. Direct Landlock backend callers receive the + same check. +- A backend that carves denials may deny a nested path inside a writable grant; + it still refuses a denial equal to or containing that grant. - Seatbelt reads are allow-default, so the profile ends with `(deny file-read* file-write* (subpath ...))`, after the write allows it overrides. `subpath` matches a single file as well as a tree.