From 5b4066a13a4055fb7bdb231da8bc857ec99fc665 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:46:57 +0300 Subject: [PATCH 01/41] test(tinybox-module): cover prospective deny roots and overlap refusal Rename the native jail test to describe its expanded scope and assert that absent deny roots are preserved so later-created credential stores stay denied. Also check that a denial overlapping a read grant is refused by the fixture backend while seatbelt still accepts it. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 d6e83b7..aaaaa58 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -206,7 +206,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"); @@ -231,20 +231,24 @@ fn denials_are_canonicalized_and_refused_where_a_backend_cannot_keep_them() path(&home.path().join(".aws")), ]; - // 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.len(), 3); + assert!(jail.deny_read.contains(&secrets.canonicalize()?)); + assert!(jail.deny_read.contains(&token.canonicalize()?)); + assert!(jail.deny_read.contains(&home.path().join(".aws"))); } // 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.len(), 3); + 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(); // A denial over the root or a writable grant is refused everywhere. From ff4f801cfe804333b6528537113f7e07ca5866a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:47:27 +0300 Subject: [PATCH 02/41] fix(native_jail): keep denied paths that do not exist yet Denials were dropped when the path did not exist, so a credential store created later would no longer be denied. Denials now retain the lexical path alongside canonical aliases for existing ancestors, and the overlap checks between grants and denials use a shared helper so nested paths are handled consistently. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail.rs | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index be000af..cfc8d80 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -45,9 +45,8 @@ pub(super) fn prepare( // A denial over the root or a writable grant would leave the command no // workspace; that is a host error, not a weaker jail. if jail.deny_read.iter().any(|denied| { - std::iter::once(&jail.root) - .chain(&jail.read_write) - .any(|granted| granted.starts_with(denied)) + denied != &jail.root && jail.root.starts_with(denied) + || jail.read_write.iter().any(|granted| overlaps(denied, granted)) }) { return Err(invalid()); } @@ -55,10 +54,7 @@ pub(super) fn prepare( // can keep the denial; outside them nothing grants the path anyway. if !backend.carves_denials() && 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)) + jail.read_only.iter().any(|granted| overlaps(denied, granted)) }) { return Err(unavailable()); @@ -96,9 +92,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. Missing credential stores must remain denied if created later. fn denials(paths: &[String]) -> Result> { let mut denied = Vec::new(); for value in paths { @@ -112,14 +107,50 @@ 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) } + +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 { + 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 => continue, + Err(error) => return Err(error), + } + } +} + +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, From 57f9456961b42d249bcc5b21f1b9b6879384722d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:47:53 +0300 Subject: [PATCH 03/41] fix(tinybox-module): allow denying read access to the jail root The deny_read check rejected any entry equal to the jail root, but a denial covering the root is a valid configuration since the workspace grant is applied separately. Removed the redundant equality comparison so only denials that are strict ancestors of the root are rejected. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index cfc8d80..d95da3c 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -45,7 +45,7 @@ pub(super) fn prepare( // A denial over the root or a writable grant would leave the command no // workspace; that is a host error, not a weaker jail. if jail.deny_read.iter().any(|denied| { - denied != &jail.root && jail.root.starts_with(denied) + jail.root.starts_with(denied) || jail.read_write.iter().any(|granted| overlaps(denied, granted)) }) { return Err(invalid()); From 8ae65696c1ff2cf187b2cb20ddaaee72f9fc32eb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:48:29 +0300 Subject: [PATCH 04/41] test(native_jail): cover landlock denial of paths created after preflight Adds a Linux-only test asserting that a path denied at preflight stays unreadable when it is created afterwards. The production change is only rustfmt reflowing two existing overlap checks. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail.rs | 9 ++++- .../src/tinybus_module/native_jail_tests.rs | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index d95da3c..252e3e1 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -46,7 +46,10 @@ pub(super) fn prepare( // workspace; that is a host error, not a weaker jail. if jail.deny_read.iter().any(|denied| { jail.root.starts_with(denied) - || jail.read_write.iter().any(|granted| overlaps(denied, granted)) + || jail + .read_write + .iter() + .any(|granted| overlaps(denied, granted)) }) { return Err(invalid()); } @@ -54,7 +57,9 @@ pub(super) fn prepare( // can keep the denial; outside them nothing grants the path anyway. if !backend.carves_denials() && jail.deny_read.iter().any(|denied| { - jail.read_only.iter().any(|granted| overlaps(denied, granted)) + jail.read_only + .iter() + .any(|granted| overlaps(denied, granted)) }) { return Err(unavailable()); 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 aaaaa58..6f915a7 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -268,3 +268,41 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() } Ok(()) } + +#[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.deny_read = vec![ + home.path() + .join(".future-store") + .to_string_lossy() + .into_owned(), + ]; + 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 mut child = backend.spawn(&jail, { + let mut shell = std::process::Command::new("/bin/sh"); + shell + .arg("-c") + .arg(format!("cat '{}' >/dev/null", credential.display())); + shell + })?; + assert!(!child.wait()?.success()); + Ok(()) +} From a70da6fe58b84765e017d7b4fcefd9dbcfb351ce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:49:00 +0300 Subject: [PATCH 05/41] test(native_jail): enable net and subprocess in landlock test The landlock preflight test now sets allow_net and allow_subprocess on the native config so the sandbox setup matches the intended scenario and the denied path assertion still exercises the read restriction. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail_tests.rs | 2 ++ 1 file changed, 2 insertions(+) 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 6f915a7..b8cd4b8 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -285,6 +285,8 @@ fn landlock_keeps_a_denied_path_unreadable_when_created_after_preflight() 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") From a3713733132c1733f779c314ebf7f86b3a4e426e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:49:18 +0300 Subject: [PATCH 06/41] docs(jail): clarify deny_read semantics across backends Reword the deny_read doc comment to describe how each backend enforces denials, note that the native module rejects overlaps an allow-list backend cannot enforce, and explain that missing paths are retained so backend rules cover later creation. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 968e97e..346d774 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -27,10 +27,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, /// Allow outbound network. Most agent tools need this; some risky tools /// (untrusted code execution) should disable it. From 90bb4aa2ba27219a3c9a3dc4b7462b93f0c4c499 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:54:01 +0300 Subject: [PATCH 07/41] fix(tinybus_module): handle missing path components in canonicalize Replace the continue in canonicalize_prospective with an empty block so the loop keeps walking up to the next ancestor when a component is not found. The continue skipped the ancestor update and could loop or return the wrong path. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 252e3e1..a0943ca 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -147,7 +147,7 @@ fn canonicalize_prospective(path: &Path) -> std::io::Result } return Ok(canonical); } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error), } } From fe74c2fa87c6eb1049935228042aa9568bc6d046 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:55:00 +0300 Subject: [PATCH 08/41] test(native_jail): cover denial of prospective credential store Extend the existing denial test to cover a deny_read path that does not exist yet, confirming the fixture backend still refuses to prepare while the seatbelt backend accepts it. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail_tests.rs | 3 +++ 1 file changed, 3 insertions(+) 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 b8cd4b8..af87ad9 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -250,6 +250,9 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() 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)] { From a2e0216415c690d3edec7415fb26c4161624398a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:56:08 +0300 Subject: [PATCH 09/41] fix(native_jail): fail closed when a denied path crosses a symlink The preflight overlap check now treats the jail root as a granted path alongside the read-only list, so a denial covering the root is caught even when no read-only grant overlaps it. Canonicalization of a prospective denied path now rejects symlinks outright, since a dangling link has no stable target to protect and silently resolving it would leave the denial unenforced. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail.rs | 13 +++++++++++-- .../src/tinybus_module/native_jail_tests.rs | 12 ++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index a0943ca..ffbbd49 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -57,8 +57,8 @@ pub(super) fn prepare( // can keep the denial; outside them nothing grants the path anyway. if !backend.carves_denials() && jail.deny_read.iter().any(|denied| { - jail.read_only - .iter() + std::iter::once(&jail.root) + .chain(&jail.read_only) .any(|granted| overlaps(denied, granted)) }) { @@ -133,6 +133,15 @@ fn canonicalize_prospective(path: &Path) -> std::io::Result let mut ancestor = path; let mut suffix = Vec::new(); loop { + match std::fs::symlink_metadata(ancestor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + // A dangling link has no stable canonical target to protect. + return Err(std::io::Error::other("credential denial crosses a symlink")); + } + 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") })?; 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 af87ad9..37fb82d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -272,6 +272,18 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() Ok(()) } +#[cfg(unix)] +#[test] +fn prospective_denials_fail_closed_for_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()); + Ok(()) +} + #[cfg(target_os = "linux")] #[test] fn landlock_keeps_a_denied_path_unreadable_when_created_after_preflight() From 33cf14f6b109816de49a63cccf11ae1c346c6eb0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:57:24 +0300 Subject: [PATCH 10/41] fix(native_jail): resolve symlink aliases for prospective denials Prospective credential denials now canonicalize existing symlink ancestors and re-append the unresolved suffix, so a path reached through an alias is protected at both its alias and real locations. Dangling links and loops still fail closed since they have no stable target to enforce. Auto-committed-on: dragonfly --- .../tinybox-module/src/tinybus_module/native_jail.rs | 9 +++++++-- .../src/tinybus_module/native_jail_tests.rs | 11 ++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index ffbbd49..c607e62 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -135,8 +135,13 @@ fn canonicalize_prospective(path: &Path) -> std::io::Result loop { match std::fs::symlink_metadata(ancestor) { Ok(metadata) if metadata.file_type().is_symlink() => { - // A dangling link has no stable canonical target to protect. - return Err(std::io::Error::other("credential denial crosses a 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 => {} 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 37fb82d..c0fb363 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -274,13 +274,22 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() #[cfg(unix)] #[test] -fn prospective_denials_fail_closed_for_dangling_symlinks() +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()])?; + assert!(roots.contains(&prospective)); + assert!(roots.contains(&target.join(".ssh"))); Ok(()) } From ed06fc4ada07d9bfdb42488a79b236ffa4e7e7c9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:58:03 +0300 Subject: [PATCH 11/41] test(native_jail): cover denied path under an uncarvable root Adds a case asserting that prepare fails when a denied path sits under a root that cannot be carved, complementing the existing check that a denied path is kept unreadable once the jail is prepared. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) 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 c0fb363..19edd2e 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -317,6 +317,10 @@ fn landlock_keeps_a_denied_path_unreadable_when_created_after_preflight() .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"))); From 014792f1a00a8f35acf3114e5d216ff8f9af2c1d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:59:10 +0300 Subject: [PATCH 12/41] test(tinybus_module): assert exact deny_read set in native jail tests The denial test now compares jail.deny_read against a fully constructed expected list, including canonicalized home paths for .ssh, .netrc and .aws, sorted and deduplicated. This replaces the previous length and membership checks so the test catches extra or missing entries rather than only verifying a subset. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 19edd2e..e55e3ce 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -230,22 +230,30 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() 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: 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.len(), 3); - assert!(jail.deny_read.contains(&secrets.canonicalize()?)); - assert!(jail.deny_read.contains(&token.canonicalize()?)); - assert!(jail.deny_read.contains(&home.path().join(".aws"))); + 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(), 3); + 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()); From 2e63b2fd7f1fdbc8359094c521aff011093b3db7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:12:03 +0300 Subject: [PATCH 13/41] test(native_jail): cover symlinked prospective denial at spawn Adds a Unix-only test asserting that spawning into a jail fails with InvalidInput when a path marked for denial is symlinked into place after preflight, guarding against a bypass where the denial is resolved too early. A small path_string helper supports the fixture setup. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 e55e3ce..7af1a6d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -301,6 +301,49 @@ fn prospective_denials_resolve_existing_aliases_and_refuse_dangling_symlinks() 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() From 283316cb558bd6fb6024183d109673c3da2e3f9f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:12:36 +0300 Subject: [PATCH 14/41] feat(jail): validate denials against allow-list grants Denied read paths are now canonicalized and checked for overlap with allow-list grants before spawning. Backends that cannot carve denials out of an allow-list would otherwise silently ignore the denial, so spawn now fails with InvalidInput instead. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 28 +++++++++++++++++++ crates/tinybox-jail/src/lib.rs | 6 +++- .../src/tinybus_module/native_jail_tests.rs | 9 ++---- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 346d774..3fc5ed1 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -124,6 +124,34 @@ impl Jail { *p = c; } } + self.deny_read = self + .deny_read + .iter() + .map(|path| crate::registry_path::canonicalize_missing(path)) + .collect::>>()?; + self.deny_read.sort(); + self.deny_read.dedup(); + Ok(()) + } + + /// Refuse denials that an allow-list backend cannot preserve after path + /// canonicalization. + pub(crate) fn validate_denials(&self, carves_denials: bool) -> std::io::Result<()> { + if carves_denials { + return Ok(()); + } + for denied in &self.deny_read { + if std::iter::once(&self.root) + .chain(&self.read_only) + .chain(&self.read_write) + .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 grant", + )); + } + } Ok(()) } diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 0007df3..f18fe57 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -99,7 +99,9 @@ pub fn default_backend() -> Arc { pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result { let mut jail = jail.clone(); jail.canonicalize()?; - default_backend().spawn(&jail, cmd) + let backend = default_backend(); + jail.validate_denials(backend.carves_denials())?; + backend.spawn(&jail, cmd) } /// Same as [`spawn`] but with a caller-supplied backend. Useful in @@ -116,6 +118,7 @@ pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result { pub fn spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> std::io::Result { let mut jail = jail.clone(); jail.canonicalize()?; + jail.validate_denials(backend.carves_denials())?; backend.spawn(&jail, cmd) } @@ -130,6 +133,7 @@ pub fn spawn_captured_with( ) -> std::io::Result { let mut jail = jail.clone(); jail.canonicalize()?; + jail.validate_denials(backend.carves_denials())?; backend.spawn_captured(&jail, cmd) } 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 7af1a6d..8820a32 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -327,12 +327,9 @@ fn spawn_rejects_a_prospective_denial_symlinked_into_the_jail() 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(); + 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) From 2fb165f001fe110290af0ed97528bf0976f238a4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:12:50 +0300 Subject: [PATCH 15/41] test(jail): cover deduplication of symlink-aliased denials Add a test asserting that canonicalize collapses two deny_read entries that resolve to the same file through a symlink, leaving a single canonical path. This guards against duplicate denials when callers list both an alias and its target. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail_tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 0cf4ffc..8d18b26 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -93,3 +93,23 @@ 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(()) +} From 69063a9e9890ea2faed1bf5c2e4f9d05fc8a26f7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:13:31 +0300 Subject: [PATCH 16/41] refactor(tinybox-jail): re-export canonicalize_missing from registry The path helper module is now re-exported through registry so callers no longer reach into the private module directly. A test signature was also collapsed onto one line. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 2 +- crates/tinybox-jail/src/jail_tests.rs | 3 +-- crates/tinybox-jail/src/registry.rs | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 3fc5ed1..37a4a03 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -127,7 +127,7 @@ impl Jail { self.deny_read = self .deny_read .iter() - .map(|path| crate::registry_path::canonicalize_missing(path)) + .map(|path| crate::registry::canonicalize_missing(path)) .collect::>>()?; self.deny_read.sort(); self.deny_read.dedup(); diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 8d18b26..42c2be1 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -96,8 +96,7 @@ fn canonicalize_keeps_missing_read_write_as_is() { #[cfg(unix)] #[test] -fn canonicalize_deduplicates_denials_that_resolve_through_symlink_aliases() --> std::io::Result<()> { +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()?; 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; From 6e2e8394733a90a2a492f880f91e15cd1a479674 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:13:49 +0300 Subject: [PATCH 17/41] docs(specs): update denial path semantics for contract 1.20 Missing denial paths are now retained so a store created after preflight stays denied, and existing aliases or missing paths beneath symlinks resolve to canonical targets. Allow-list backends recheck canonicalized denials at spawn to catch paths created or redirected after preflight. Auto-committed-on: dragonfly --- docs/specs/contract-1-20.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/specs/contract-1-20.md b/docs/specs/contract-1-20.md index 8ac1663..4212067 100644 --- a/docs/specs/contract-1-20.md +++ b/docs/specs/contract-1-20.md @@ -17,13 +17,17 @@ 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. +- Allow-list backends recheck canonicalized denials at spawn and refuse if a + path created or redirected after preflight now overlaps the root or a 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. From 368373fa9d66e4d2c615f53e9f57b16f1d543719 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:14:01 +0300 Subject: [PATCH 18/41] docs(tinybus): clarify deny_read path handling Describe how the module retains prospective deny_read paths and resolves them against current filesystem aliases at preflight and just before spawn, rather than canonicalizing and dropping nonexistent paths up front. Also note that Landlock and AppContainer deny paths outside their allow-lists. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index d1e4462..7870095 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -252,11 +252,12 @@ 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, and refuses +canonicalized overlap with the root or a grant when the backend cannot subtract +denials. Seatbelt denies them last in its profile; Landlock and `AppContainer` +deny paths outside their allow-lists. The jail owns a separate native collector for each execution. Output replay, Cancel, ReleaseOutput, Close and Shutdown use the same retained cleanup barriers From d895929cba0d890aa731f5d7a60dcce167bd27fb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:14:56 +0300 Subject: [PATCH 19/41] chore: files changed crates/tinybox-jail/src/registry_path.rs Auto-committed-on: dragonfly --- crates/tinybox-jail/src/registry_path.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)) From 5c6bf1889740d0cfe5d5e332dd17b4da8fd4c8a1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:23:24 +0300 Subject: [PATCH 20/41] test(tinybus_module): cover symlink loops in denial resolution Extend the existing denial-resolution test to build a pair of mutually referencing symlinks and assert that resolving them fails instead of looping forever. Auto-committed-on: dragonfly --- .../tinybox-module/src/tinybus_module/native_jail_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) 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 8820a32..0067044 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -298,6 +298,12 @@ fn prospective_denials_resolve_existing_aliases_and_refuse_dangling_symlinks() let roots = denials(&[prospective.to_string_lossy().into_owned()])?; assert!(roots.contains(&prospective)); assert!(roots.contains(&target.join(".ssh"))); + + 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(()) } From 82e4838a577e61d4e7a8c7b11aca410fa3f9367e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:36:58 +0300 Subject: [PATCH 21/41] test(native_jail): cover collector building a host from a valid jail Adds a Unix-only test that runs the collector against a valid native jail and asserts it returns a host without error. This covers the happy path that the existing tests, which focus on denial and symlink rejection cases, leave untested. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 0067044..e5ab743 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -307,6 +307,18 @@ fn prospective_denials_resolve_existing_aliases_and_refuse_dangling_symlinks() 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 _collector = collector(root.path(), &config(), &command)?; + Ok(()) +} + #[cfg(unix)] #[test] fn spawn_rejects_a_prospective_denial_symlinked_into_the_jail() From 6cfba78200cd67c3906edb1618dfbc797c4ead71 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:37:51 +0300 Subject: [PATCH 22/41] test(tinybus_module): handle unavailable native jail backend The collector test now checks whether the native jail backend is available and asserts a successful collection when it is, or the unsupported operation error when it is not. This keeps the test passing on hosts without the required jail support. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 e5ab743..661fb57 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -315,7 +315,17 @@ fn collector_builds_a_host_from_a_valid_native_jail() let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); command.cwd = Some(root.path().to_path_buf()); - let _collector = collector(root.path(), &config(), &command)?; + let backend = tinybox_jail::default_backend(); + let result = collector(root.path(), &config(), &command); + if backend.is_available() { + assert!(result.is_ok()); + } else { + assert!(matches!( + result, + Err(tinybus::Error::MethodFailed { ref name, .. }) + if name == tinybox_bus::UNSUPPORTED_OPERATION + )); + } Ok(()) } From 4e742cfdb0435febd60963628a2dd5e132802a0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:38:23 +0300 Subject: [PATCH 23/41] test(native_jail): gate collector assertion on jail preparation The test now checks that the jail can actually be prepared before asserting that the collector succeeds, so environments where the backend is available but preparation fails no longer produce a false failure. Auto-committed-on: dragonfly --- .../tinybox-module/src/tinybus_module/native_jail_tests.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 661fb57..35884e6 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -316,8 +316,11 @@ fn collector_builds_a_host_from_a_valid_native_jail() command.cwd = Some(root.path().to_path_buf()); let backend = tinybox_jail::default_backend(); - let result = collector(root.path(), &config(), &command); - if backend.is_available() { + let config = config(); + let can_enforce = backend.is_available() + && prepare(root.path(), &config, &command, backend.as_ref()).is_ok(); + let result = collector(root.path(), &config, &command); + if can_enforce { assert!(result.is_ok()); } else { assert!(matches!( From 7c24bbea1afda461c3a291bb04501546476280d0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:41:22 +0300 Subject: [PATCH 24/41] style(tinybox-module): reflow native jail test condition Reformatted the can_enforce expression in the native jail test to fit within the line width limit. No behaviour change. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 35884e6..b2ab826 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -317,8 +317,8 @@ fn collector_builds_a_host_from_a_valid_native_jail() let backend = tinybox_jail::default_backend(); let config = config(); - let can_enforce = backend.is_available() - && prepare(root.path(), &config, &command, backend.as_ref()).is_ok(); + let can_enforce = + backend.is_available() && prepare(root.path(), &config, &command, backend.as_ref()).is_ok(); let result = collector(root.path(), &config, &command); if can_enforce { assert!(result.is_ok()); From c6e53d879ca4a77d569697516ab315938fdb9369 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:42:27 +0300 Subject: [PATCH 25/41] refactor(native_jail): inject jail backend into collector The collector now takes the jail backend as a parameter instead of constructing the default backend internally. This lets callers supply a custom backend and makes the function easier to test. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index c607e62..03d692a 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -187,8 +187,8 @@ 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) From 848bb040e0973b2cbef80934c38c5f0b5c6fe6fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:43:10 +0300 Subject: [PATCH 26/41] test(native_jail): inject a fixture jail backend into the collector test The collector test now passes an explicit fixture backend instead of probing the real jail backend and branching on whether enforcement is available. This makes the test deterministic and removes the dependency on host jail support. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 21 +++++++------------ .../src/tinybus_module/resources_stream.rs | 5 ++++- 2 files changed, 11 insertions(+), 15 deletions(-) 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 b2ab826..a7dcf71 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -315,20 +315,13 @@ fn collector_builds_a_host_from_a_valid_native_jail() let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); command.cwd = Some(root.path().to_path_buf()); - let backend = tinybox_jail::default_backend(); - let config = config(); - let can_enforce = - backend.is_available() && prepare(root.path(), &config, &command, backend.as_ref()).is_ok(); - let result = collector(root.path(), &config, &command); - if can_enforce { - assert!(result.is_ok()); - } else { - assert!(matches!( - result, - Err(tinybus::Error::MethodFailed { ref name, .. }) - if name == tinybox_bus::UNSUPPORTED_OPERATION - )); - } + let backend: Arc = Arc::new(Backend { + available: true, + suitable: true, + name: "fixture", + full: true, + }); + let _collector = collector(root.path(), &config(), &command, backend)?; 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( From 900120d13c22d94a63c256a2e7bf5a6640455bf8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:51:56 +0300 Subject: [PATCH 27/41] test(tinybus_module): tighten alias denial assertion The test now compares the full set of resolved denial roots against a sorted, deduplicated expectation instead of checking membership of two paths individually. This catches extra or missing roots that the previous assertions would have let through. Auto-committed-on: dragonfly --- .../tinybox-module/src/tinybus_module/native_jail_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 a7dcf71..80f7544 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -296,8 +296,11 @@ fn prospective_denials_resolve_existing_aliases_and_refuse_dangling_symlinks() 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!(roots.contains(&target.join(".ssh"))); + assert_eq!(roots, expected); let first_loop = dir.path().join("loop-a"); let second_loop = dir.path().join("loop-b"); From 7231c6400b2d8525c925d21f9f190c0f1446339e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:29:40 +0300 Subject: [PATCH 28/41] feat(jail): report implicit grants from jail backends Landlock grants read access to system and device baseline paths that callers could not previously see, so a credential denial overlapping one of those paths would be silently overridden. Backends now expose their implicit grants and both native preflight and direct Landlock spawns reject denials that overlap them. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 10 +++ crates/tinybox-jail/src/linux.rs | 38 ++++++++++- crates/tinybox-jail/src/linux_tests.rs | 42 ++++++++++++ .../src/tinybus_module/README.md | 10 +-- .../src/tinybus_module/native_jail.rs | 37 ++++++---- .../src/tinybus_module/native_jail_tests.rs | 68 +++++++++++++++++++ docs/specs/contract-1-20.md | 14 ++-- 7 files changed, 197 insertions(+), 22 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 81476e5..5a1cfc2 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -196,6 +196,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/linux.rs b/crates/tinybox-jail/src/linux.rs index 1aecfdf..07522ca 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,8 +128,36 @@ 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)?; + validate_baseline_denials(&jail, &self.implicit_grants())?; + imp::spawn(&jail, cmd) + } +} + +/// Reject denials that overlap paths the Landlock ruleset grants implicitly. +fn validate_baseline_denials(jail: &Jail, baseline: &[std::path::PathBuf]) -> io::Result<()> { + let mut grants = Vec::new(); + for grant in baseline { + match grant.canonicalize() { + Ok(canonical) => grants.push(canonical), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + for denied in &jail.deny_read { + if grants + .iter() + .any(|granted| denied.starts_with(granted) || granted.starts_with(denied)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "denied path overlaps an implicit Landlock grant", + )); + } } + Ok(()) } #[cfg(feature = "landlock")] diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index 8730130..101b917 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -231,6 +231,48 @@ 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(()) +} + /// 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-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 5b7bd8f..22664ee 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -254,10 +254,12 @@ 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). 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, and refuses -canonicalized overlap with the root or a grant when the backend cannot subtract -denials. Seatbelt denies them last in its profile; Landlock and `AppContainer` -deny paths outside their allow-lists. +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 78a5d40..5584341 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -55,26 +55,34 @@ 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| { jail.root.starts_with(denied) - || jail - .read_write - .iter() - .any(|granted| overlaps(denied, granted)) + || 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 grant.canonicalize() { + Ok(canonical) => implicit_grants.push(canonical), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(unavailable()), + } + } + if jail.deny_read.iter().any(|denied| { std::iter::once(&jail.root) .chain(&jail.read_only) + .chain(&implicit_grants) .any(|granted| overlaps(denied, granted)) - }) - { - return Err(unavailable()); + }) { + return Err(unavailable()); + } } backend .require_resources(&jail) @@ -94,6 +102,7 @@ pub(super) fn prepare( } Ok(jail) } +/// Validate and canonicalize each existing absolute grant path. fn grants(paths: &[String]) -> Result> { paths .iter() @@ -113,7 +122,7 @@ fn grants(paths: &[String]) -> Result> { .collect() } /// Keep lexical prospective paths as well as canonical aliases for existing -/// ancestors. Missing credential stores must remain denied if created later. +/// ancestors so a later-created credential store remains denied. fn denials(paths: &[String]) -> Result> { let mut denied = Vec::new(); for value in paths { @@ -139,6 +148,7 @@ fn denials(paths: &[String]) -> Result> { 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), @@ -182,6 +192,7 @@ fn canonicalize_prospective(path: &Path) -> std::io::Result } } +/// 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) } 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 83cfbf5..e903036 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![], @@ -304,6 +328,15 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() 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()]; @@ -312,6 +345,41 @@ fn denials_preserve_prospective_roots_and_refuse_unenforceable_overlaps() 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 prospective_denials_resolve_existing_aliases_and_refuse_dangling_symlinks() diff --git a/docs/specs/contract-1-20.md b/docs/specs/contract-1-20.md index 4212067..fff4b26 100644 --- a/docs/specs/contract-1-20.md +++ b/docs/specs/contract-1-20.md @@ -23,11 +23,17 @@ at least 20, which `tinybox_bus::is_compatible` already enforces. 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 or a grant. + 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. From cf9c68983df2653bb19560989803cd4eb44f9277 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:31:31 +0300 Subject: [PATCH 29/41] test: cover landlock baseline validation for future roots Auto-committed-on: dragonfly --- crates/tinybox-jail/src/linux_tests.rs | 17 ++++++++++ .../src/tinybus_module/native_jail_tests.rs | 31 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index 101b917..049608f 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -273,6 +273,23 @@ fn raw_landlock_spawn_refuses_a_deny_root_that_overlaps_implicit_baseline_access Ok(()) } +#[test] +fn landlock_validation_keeps_future_baseline_roots_protected() -> io::Result<()> { + let root = tempfile::tempdir()?; + let future_baseline = root.path().join("baseline-created-after-preflight"); + let mut jail = jail_for(root.path()); + jail.deny_read + .push(future_baseline.join("future-credential").to_path_buf()); + + let result = validate_baseline_denials(&jail, &[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-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index e903036..1f15057 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -380,6 +380,37 @@ fn allow_list_backend_rejects_denials_over_implicit_baseline_grants() 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() From d1813c183db496c54e82c2e66e7180c5414273b6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:32:12 +0300 Subject: [PATCH 30/41] refactor(jail): reuse canonicalize helpers for missing paths Replace the inline canonicalize-and-ignore-NotFound logic in the Landlock baseline check and the native jail module with the shared canonicalize_missing and canonicalize_prospective helpers. This keeps path canonicalization behaviour consistent across backends and removes duplicated error handling. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/linux.rs | 6 +----- crates/tinybox-module/src/tinybus_module/native_jail.rs | 3 +-- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-jail/src/linux.rs b/crates/tinybox-jail/src/linux.rs index 07522ca..eb42b50 100644 --- a/crates/tinybox-jail/src/linux.rs +++ b/crates/tinybox-jail/src/linux.rs @@ -140,11 +140,7 @@ impl JailBackend for LandlockBackend { fn validate_baseline_denials(jail: &Jail, baseline: &[std::path::PathBuf]) -> io::Result<()> { let mut grants = Vec::new(); for grant in baseline { - match grant.canonicalize() { - Ok(canonical) => grants.push(canonical), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } + grants.push(crate::registry::canonicalize_missing(grant)?); } for denied in &jail.deny_read { if grants diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 5584341..b289898 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -69,9 +69,8 @@ pub(super) fn prepare( if !backend.carves_denials() { let mut implicit_grants = Vec::new(); for grant in backend.implicit_grants() { - match grant.canonicalize() { + match canonicalize_prospective(&grant) { Ok(canonical) => implicit_grants.push(canonical), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(_) => return Err(unavailable()), } } From 4512bdf88955977ee1ff79a26e46d8f486635060 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:33:24 +0300 Subject: [PATCH 31/41] docs(tinybus_module): document native jail preflight and collector Added doc comments to the prepare and collector functions in the native jail module to clarify that path validation and policy checks happen before process admission and that the collector is only created once preflight passes. Auto-committed-on: dragonfly --- crates/tinybox-module/src/tinybus_module/native_jail.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index b289898..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, @@ -208,6 +209,7 @@ fn unavailable() -> Error { ) } +/// Create an owned collector only after the native jail passes preflight. pub(super) fn collector( root: &Path, config: &NativeJailConfig, From f05b6040c3f0a85fee60ef370050af01554a5e73 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:34:15 +0300 Subject: [PATCH 32/41] test(tinybox-jail): drop redundant to_path_buf on join result The path built by joining a component onto a PathBuf already yields a PathBuf, so the extra to_path_buf call was unnecessary. Removing it keeps the test setup unchanged in behaviour while avoiding a pointless allocation. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/linux_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index 049608f..27c17f7 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -279,7 +279,7 @@ fn landlock_validation_keeps_future_baseline_roots_protected() -> io::Result<()> let future_baseline = root.path().join("baseline-created-after-preflight"); let mut jail = jail_for(root.path()); jail.deny_read - .push(future_baseline.join("future-credential").to_path_buf()); + .push(future_baseline.join("future-credential")); let result = validate_baseline_denials(&jail, &[future_baseline]); assert_eq!( From dcbe97e5390a5d66b8cce15a4610be9bd99230d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:56:50 +0300 Subject: [PATCH 33/41] test(tinybox-jail): cover denials nested under backend implicit grants Adds a test asserting the facade rejects a jail whose read denial falls inside a path the backend implicitly grants, and that the backend is never spawned in that case. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/mod_tests.rs | 47 ++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 1713381..fe37c5a 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -157,6 +157,53 @@ 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.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(()) +} + #[test] fn default_backend_returns_something() { let b = default_backend(); From bf16184b0a56f470279d36f0b22252af0fa7dd3e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:57:07 +0300 Subject: [PATCH 34/41] test(tinybox-jail): isolate baseline dir from jail root The baseline directory used by the nested-denial test now lives in its own temporary directory instead of inside the jail root, so the fixture no longer depends on the jail's own path layout. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/mod_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index fe37c5a..466061c 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -182,7 +182,8 @@ fn facade_refuses_denials_nested_under_backend_implicit_grants() -> std::io::Res } let root = tempfile::tempdir()?; - let baseline = root.path().join("implicit-baseline"); + 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"); From 2c7acb37227ec94215a264cb22d137b4db009e5f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:57:42 +0300 Subject: [PATCH 35/41] refactor(jail): fold implicit grant validation into validate_denials The separate validate_baseline_denials helper is removed and its canonicalization and overlap checks are merged into Jail::validate_denials, which now takes the backend's implicit grants as an argument. This keeps all denial validation in one place so every spawn path applies the same rules. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 13 +++++++++++-- crates/tinybox-jail/src/lib.rs | 6 +++--- crates/tinybox-jail/src/linux.rs | 23 +---------------------- crates/tinybox-jail/src/linux_tests.rs | 2 +- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 5a1cfc2..049ca4f 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -157,19 +157,28 @@ impl Jail { /// Refuse denials that an allow-list backend cannot preserve after path /// canonicalization. - pub(crate) fn validate_denials(&self, carves_denials: bool) -> std::io::Result<()> { + pub(crate) fn validate_denials( + &self, + carves_denials: bool, + implicit_grants: &[PathBuf], + ) -> std::io::Result<()> { if carves_denials { 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 grant", + "denied path overlaps an allow-list or implicit grant", )); } } diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 2456d54..8bdf9b1 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -101,7 +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())?; + jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; backend.spawn(&jail, cmd) } @@ -120,7 +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())?; + jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; backend.spawn(&jail, cmd) } @@ -136,7 +136,7 @@ pub fn spawn_captured_with( let mut jail = jail.clone(); jail.canonicalize()?; require_resources(backend, &jail)?; - jail.validate_denials(backend.carves_denials())?; + 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 eb42b50..9123c57 100644 --- a/crates/tinybox-jail/src/linux.rs +++ b/crates/tinybox-jail/src/linux.rs @@ -130,32 +130,11 @@ impl JailBackend for LandlockBackend { .map_err(|error| io::Error::new(io::ErrorKind::Unsupported, error))?; let mut jail = jail.clone(); jail.canonicalize()?; - jail.validate_denials(false)?; - validate_baseline_denials(&jail, &self.implicit_grants())?; + jail.validate_denials(false, &self.implicit_grants())?; imp::spawn(&jail, cmd) } } -/// Reject denials that overlap paths the Landlock ruleset grants implicitly. -fn validate_baseline_denials(jail: &Jail, baseline: &[std::path::PathBuf]) -> io::Result<()> { - let mut grants = Vec::new(); - for grant in baseline { - grants.push(crate::registry::canonicalize_missing(grant)?); - } - for denied in &jail.deny_read { - if grants - .iter() - .any(|granted| denied.starts_with(granted) || granted.starts_with(denied)) - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "denied path overlaps an implicit Landlock grant", - )); - } - } - Ok(()) -} - #[cfg(feature = "landlock")] mod imp { use std::fs::File; diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index 27c17f7..fac5a28 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -281,7 +281,7 @@ fn landlock_validation_keeps_future_baseline_roots_protected() -> io::Result<()> jail.deny_read .push(future_baseline.join("future-credential")); - let result = validate_baseline_denials(&jail, &[future_baseline]); + let result = jail.validate_denials(false, &[future_baseline]); assert_eq!( result.err().map(|error| error.kind()), Some(io::ErrorKind::InvalidInput), From f16b7d2f15b2069eff5bda2cbfc609b4dbda109c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:58:02 +0300 Subject: [PATCH 36/41] test(tinybox-jail): reformat denial conflict assertion Reformatted the marker existence assertion in the nested denial test to satisfy rustfmt line width limits. No behaviour change. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/mod_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 466061c..1a1f46b 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -201,7 +201,10 @@ fn facade_refuses_denials_nested_under_backend_implicit_grants() -> std::io::Res 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"); + assert!( + !marker.exists(), + "backend must not spawn after denial conflict" + ); Ok(()) } From aeab136179be294367f365eac9f30859aee4391f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:58:39 +0300 Subject: [PATCH 37/41] docs(tinybox-jail): clarify validate_denials doc comment Reword the doc comment on validate_denials to describe the refusal in terms of non-carving backends and their explicit and implicit grants, rather than allow-list backends. The test for future baseline roots now places the baseline path in its own temporary directory so it stays outside the jail root. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 4 ++-- crates/tinybox-jail/src/linux_tests.rs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 049ca4f..a47e313 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -155,8 +155,8 @@ impl Jail { Ok(()) } - /// Refuse denials that an allow-list backend cannot preserve after path - /// canonicalization. + /// 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, diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index fac5a28..fd1e02e 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -276,7 +276,10 @@ fn raw_landlock_spawn_refuses_a_deny_root_that_overlaps_implicit_baseline_access #[test] fn landlock_validation_keeps_future_baseline_roots_protected() -> io::Result<()> { let root = tempfile::tempdir()?; - let future_baseline = root.path().join("baseline-created-after-preflight"); + 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")); From c6acd9dcdda0221ebaa3c8a110b255593c5aa615 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 02:04:16 +0300 Subject: [PATCH 38/41] test(jail): cover dangling implicit grants in facade spawn Adds two facade tests for a backend whose implicit grant is a dangling symlink. One confirms spawning still succeeds when no denials are configured, and the other confirms a dangling grant combined with a denial fails with NotFound before the backend is invoked. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/mod_tests.rs | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 1a1f46b..bdcf727 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -208,6 +208,100 @@ fn facade_refuses_denials_nested_under_backend_implicit_grants() -> std::io::Res 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(); From e3c6db784e2e83786de263918b74fc2f1ca974ef Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 02:04:34 +0300 Subject: [PATCH 39/41] fix(tinybox-jail): skip read carve-outs when no deny paths exist The read carve-out setup now returns early when the jail has no deny-read paths, avoiding unnecessary work in that case. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/jail.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index a47e313..29b72fb 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -162,7 +162,7 @@ impl Jail { carves_denials: bool, implicit_grants: &[PathBuf], ) -> std::io::Result<()> { - if carves_denials { + if carves_denials || self.deny_read.is_empty() { return Ok(()); } let implicit_grants = implicit_grants From 576505c8480108efbd8b520d1afd4075c6837337 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 02:04:45 +0300 Subject: [PATCH 40/41] style(tinybox-jail): wrap long deny_read push call Reformatted the deny_read push in the dangling-baseline test to wrap across lines so it stays within the line length limit. No behaviour change. Auto-committed-on: dragonfly --- crates/tinybox-jail/src/mod_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index bdcf727..39028c5 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -294,7 +294,8 @@ fn facade_refuses_denials_when_implicit_grant_is_dangling() -> std::io::Result<( spawned: spawned.clone(), }; let mut jail = Jail::new(root.path(), "denial-with-dangling-baseline"); - jail.deny_read.push(denial_root.path().join("future-denial")); + 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); From a6a7ea0a6e1a092c5566850d1810bbac31eda2cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 02:29:03 +0300 Subject: [PATCH 41/41] test(native_jail): assert allowed reads succeed under landlock The landlock test now reads an allowed file before touching the denied credential path, so a failure can no longer be mistaken for a blanket denial of all reads. The captured stdout confirms the allowed read succeeded while the command as a whole still exits non-zero. Auto-committed-on: dragonfly --- .../src/tinybus_module/native_jail_tests.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 1f15057..d7b44ab 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -532,13 +532,19 @@ fn landlock_keeps_a_denied_path_unreadable_when_created_after_preflight() std::fs::create_dir(home.path().join(".future-store"))?; std::fs::write(&credential, "secret")?; - let mut child = backend.spawn(&jail, { + 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 - .arg("-c") - .arg(format!("cat '{}' >/dev/null", credential.display())); - shell - })?; - assert!(!child.wait()?.success()); + })? + .wait_with_output()?; + assert_eq!(output.stdout, b"allowed-read-control\n"); + assert!(!output.status.success()); Ok(()) }