Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
5b4066a
test(tinybox-module): cover prospective deny roots and overlap refusal
senamakel Oct 18, 2026
ff4f801
fix(native_jail): keep denied paths that do not exist yet
senamakel Oct 18, 2026
57f9456
fix(tinybox-module): allow denying read access to the jail root
senamakel Oct 18, 2026
8ae6569
test(native_jail): cover landlock denial of paths created after prefl…
senamakel Oct 18, 2026
a70da6f
test(native_jail): enable net and subprocess in landlock test
senamakel Oct 18, 2026
a371373
docs(jail): clarify deny_read semantics across backends
senamakel Oct 18, 2026
90bb4aa
fix(tinybus_module): handle missing path components in canonicalize
senamakel Oct 18, 2026
fe74c2f
test(native_jail): cover denial of prospective credential store
senamakel Oct 18, 2026
a2e0216
fix(native_jail): fail closed when a denied path crosses a symlink
senamakel Oct 18, 2026
33cf14f
fix(native_jail): resolve symlink aliases for prospective denials
senamakel Oct 18, 2026
ed06fc4
test(native_jail): cover denied path under an uncarvable root
senamakel Oct 18, 2026
014792f
test(tinybus_module): assert exact deny_read set in native jail tests
senamakel Oct 18, 2026
59c0eb5
Merge remote-tracking branch 'origin/main' into epic-7328-tinybox-den…
senamakel Oct 18, 2026
2e63b2f
test(native_jail): cover symlinked prospective denial at spawn
senamakel Oct 18, 2026
283316c
feat(jail): validate denials against allow-list grants
senamakel Oct 18, 2026
2fb165f
test(jail): cover deduplication of symlink-aliased denials
senamakel Oct 18, 2026
69063a9
refactor(tinybox-jail): re-export canonicalize_missing from registry
senamakel Oct 18, 2026
6e2e839
docs(specs): update denial path semantics for contract 1.20
senamakel Oct 18, 2026
368373f
docs(tinybus): clarify deny_read path handling
senamakel Oct 18, 2026
d895929
chore: files changed crates/tinybox-jail/src/registry_path.rs
senamakel Oct 18, 2026
5c6bf18
test(tinybus_module): cover symlink loops in denial resolution
senamakel Oct 18, 2026
82e4838
test(native_jail): cover collector building a host from a valid jail
senamakel Oct 18, 2026
6cfba78
test(tinybus_module): handle unavailable native jail backend
senamakel Oct 18, 2026
4e742cf
test(native_jail): gate collector assertion on jail preparation
senamakel Oct 18, 2026
7c24bbe
style(tinybox-module): reflow native jail test condition
senamakel Oct 18, 2026
c6e53d8
refactor(native_jail): inject jail backend into collector
senamakel Oct 18, 2026
848bb04
test(native_jail): inject a fixture jail backend into the collector test
senamakel Oct 18, 2026
900120d
test(tinybus_module): tighten alias denial assertion
senamakel Oct 18, 2026
6809c9e
Merge remote-tracking branch 'origin/main' into epic-7328-protected-r…
senamakel Oct 18, 2026
373e4fb
Merge branch 'epic-7328-tinybox-deny-roots' of https://github.com/tin…
senamakel Oct 18, 2026
7231c64
feat(jail): report implicit grants from jail backends
senamakel Oct 18, 2026
cf9c689
test: cover landlock baseline validation for future roots
senamakel Oct 18, 2026
d1813c1
refactor(jail): reuse canonicalize helpers for missing paths
senamakel Oct 18, 2026
4512bdf
docs(tinybus_module): document native jail preflight and collector
senamakel Oct 18, 2026
f05b604
test(tinybox-jail): drop redundant to_path_buf on join result
senamakel Oct 18, 2026
dcbe97e
test(tinybox-jail): cover denials nested under backend implicit grants
senamakel Oct 18, 2026
bf16184
test(tinybox-jail): isolate baseline dir from jail root
senamakel Oct 18, 2026
2c7acb3
refactor(jail): fold implicit grant validation into validate_denials
senamakel Oct 18, 2026
f16b7d2
test(tinybox-jail): reformat denial conflict assertion
senamakel Oct 18, 2026
aeab136
docs(tinybox-jail): clarify validate_denials doc comment
senamakel Oct 18, 2026
c6acd9d
test(jail): cover dangling implicit grants in facade spawn
senamakel Oct 18, 2026
e3c6db7
fix(tinybox-jail): skip read carve-outs when no deny paths exist
senamakel Oct 18, 2026
576505c
style(tinybox-jail): wrap long deny_read push call
senamakel Oct 18, 2026
a6a7ea0
test(native_jail): assert allowed reads succeed under landlock
senamakel Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions crates/tinybox-jail/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ pub struct Jail {
/// must write to without it landing inside the root (e.g. a per-call
/// output-capture directory). Grant the narrowest directory that works.
pub read_write: Vec<PathBuf>,
/// 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<PathBuf>,
/// Optional hard CPU, memory, process and writable-disk limits.
///
Expand Down Expand Up @@ -144,6 +145,43 @@ impl Jail {
*p = c;
}
}
self.deny_read = self
.deny_read
.iter()
.map(|path| crate::registry::canonicalize_missing(path))
.collect::<std::io::Result<Vec<_>>>()?;
self.deny_read.sort();
self.deny_read.dedup();
Ok(())
}

/// Refuse denials that a non-carving backend cannot preserve against its
/// explicit and implicit grants after path canonicalization.
pub(crate) fn validate_denials(
&self,
carves_denials: bool,
implicit_grants: &[PathBuf],
) -> std::io::Result<()> {
if carves_denials || self.deny_read.is_empty() {
return Ok(());
}
let implicit_grants = implicit_grants
.iter()
.map(|path| crate::registry::canonicalize_missing(path))
.collect::<std::io::Result<Vec<_>>>()?;
Comment thread
senamakel marked this conversation as resolved.
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))
Comment thread
senamakel marked this conversation as resolved.
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"denied path overlaps an allow-list or implicit grant",
));
}
}
Ok(())
}

Expand All @@ -167,6 +205,16 @@ impl Jail {
/// `AppContainer` requires custom `CreateProcess` flags that `std`'s
/// `Command::spawn` does not expose.
pub trait JailBackend: Send + Sync {
/// Paths the backend implicitly grants in addition to explicit jail roots.
///
/// Callers enforcing hard denials use these paths to detect overlaps that
/// the backend's allow rules would otherwise override. Backends should
/// report every recursively granted baseline path; the default means the
/// backend has no implicit filesystem grants.
fn implicit_grants(&self) -> Vec<PathBuf> {
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`.
Expand Down
19 changes: 19 additions & 0 deletions crates/tinybox-jail/src/jail_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,22 @@ fn canonicalize_keeps_missing_read_write_as_is() {
j.canonicalize().unwrap();
assert_eq!(j.read_write, vec![PathBuf::from("/this/never/existed")]);
}

#[cfg(unix)]
#[test]
fn canonicalize_deduplicates_denials_that_resolve_through_symlink_aliases() -> std::io::Result<()> {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir()?;
let store = tempfile::tempdir()?;
let credential = store.path().join("credential");
std::fs::write(&credential, b"secret")?;
let alias = root.path().join("credential-alias");
symlink(&credential, &alias)?;
let mut jail = Jail::new(root.path(), "alias-denial");
jail.deny_read = vec![alias, credential.clone()];

jail.canonicalize()?;

assert_eq!(jail.deny_read, vec![credential.canonicalize()?]);
Ok(())
}
3 changes: 3 additions & 0 deletions crates/tinybox-jail/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result<Child> {
jail.canonicalize()?;
let backend = default_backend();
require_resources(backend.as_ref(), &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
backend.spawn(&jail, cmd)
}

Expand All @@ -119,6 +120,7 @@ pub fn spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> std::
let mut jail = jail.clone();
jail.canonicalize()?;
require_resources(backend, &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
backend.spawn(&jail, cmd)
}

Expand All @@ -134,6 +136,7 @@ pub fn spawn_captured_with(
let mut jail = jail.clone();
jail.canonicalize()?;
require_resources(backend, &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
backend.spawn_captured(&jail, cmd)
}

Expand Down
13 changes: 12 additions & 1 deletion crates/tinybox-jail/src/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ impl LandlockBackend {
}

impl JailBackend for LandlockBackend {
fn implicit_grants(&self) -> Vec<std::path::PathBuf> {
SYSTEM_READ_PATHS
.iter()
.chain(DEVICE_PATHS)
.map(std::path::PathBuf::from)
.collect()
}

fn name(&self) -> &'static str {
LANDLOCK_BACKEND_NAME
}
Expand Down Expand Up @@ -120,7 +128,10 @@ impl JailBackend for LandlockBackend {
self.constraint_support()
.require(self.name(), &requested)
.map_err(|error| io::Error::new(io::ErrorKind::Unsupported, error))?;
imp::spawn(jail, cmd)
let mut jail = jail.clone();
jail.canonicalize()?;
jail.validate_denials(false, &self.implicit_grants())?;
imp::spawn(&jail, cmd)
}
}

Expand Down
62 changes: 62 additions & 0 deletions crates/tinybox-jail/src/linux_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,68 @@ fn baseline_lets_the_resolver_config_be_read_through_its_symlink() -> io::Result
Ok(())
}

#[test]
fn raw_landlock_spawn_refuses_a_deny_root_that_overlaps_implicit_baseline_access() -> io::Result<()>
{
if !available() {
return Ok(());
}
let root = tempfile::tempdir()?;
let backend = LandlockBackend::new();
let baseline = SYSTEM_READ_PATHS
.iter()
.chain(DEVICE_PATHS)
.filter(|path| Path::new(path).exists())
.collect::<Vec<_>>();
assert!(
!baseline.is_empty(),
"the test host must expose baseline paths"
);
for path in baseline {
let mut jail = jail_for(root.path());
jail.deny_read.push(Path::new(path).to_path_buf());
let result = backend.spawn(&jail, Command::new("/bin/true"));
assert_eq!(
result.err().map(|error| error.kind()),
Some(io::ErrorKind::InvalidInput),
"raw LandlockBackend::spawn must refuse deny root {path} that overlaps an implicit baseline grant"
);
}

// `/bin` is commonly a symlink to `/usr/bin`; prospective denials through
// the alias must also conflict with the canonical baseline grant.
let mut aliased = jail_for(root.path());
aliased
.deny_read
.push(Path::new("/bin/tinybox-future-credential").to_path_buf());
let result = backend.spawn(&aliased, Command::new("/bin/true"));
assert_eq!(
result.err().map(|error| error.kind()),
Some(io::ErrorKind::InvalidInput)
);
Ok(())
}

#[test]
fn landlock_validation_keeps_future_baseline_roots_protected() -> io::Result<()> {
let root = tempfile::tempdir()?;
let baseline_root = tempfile::tempdir()?;
let future_baseline = baseline_root
.path()
.join("baseline-created-after-preflight");
let mut jail = jail_for(root.path());
jail.deny_read
.push(future_baseline.join("future-credential"));

let result = jail.validate_denials(false, &[future_baseline]);
assert_eq!(
result.err().map(|error| error.kind()),
Some(io::ErrorKind::InvalidInput),
"a prospective baseline grant could appear before Landlock opens its rules"
);
Ok(())
}

/// File grants must enforce fully without granting their parent directory.
#[test]
fn individual_file_grants_preserve_read_only_and_read_write_access() -> io::Result<()> {
Expand Down
146 changes: 146 additions & 0 deletions crates/tinybox-jail/src/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,152 @@ fn facade_refuses_each_zero_resource_limit_even_when_backend_claims_enforcement(
Ok(())
}

#[test]
fn facade_refuses_denials_nested_under_backend_implicit_grants() -> std::io::Result<()> {
struct BaselineBackend {
baseline: std::path::PathBuf,
}

impl JailBackend for BaselineBackend {
fn implicit_grants(&self) -> Vec<std::path::PathBuf> {
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<std::process::Child> {
cmd.spawn()
}
}

let root = tempfile::tempdir()?;
let baseline_root = tempfile::tempdir()?;
let baseline = baseline_root.path().join("implicit-baseline");
std::fs::create_dir(&baseline)?;
let marker = root.path().join("executed");
let mut jail = Jail::new(root.path(), "baseline-denial");
jail.deny_read.push(baseline.join("future-secret"));
let mut command = if cfg!(windows) {
let mut command = Command::new("cmd");
command.args(["/C", "echo executed > executed"]);
command
} else {
let mut command = Command::new("sh");
command.args(["-c", "printf executed > executed"]);
command
};
command.current_dir(root.path());

let error = spawn_with(&BaselineBackend { baseline }, &jail, command).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(
!marker.exists(),
"backend must not spawn after denial conflict"
);
Ok(())
}

#[cfg(unix)]
#[test]
fn facade_spawns_without_denials_when_optional_implicit_grant_is_dangling() -> std::io::Result<()> {
struct BaselineBackend {
baseline: std::path::PathBuf,
spawned: std::sync::Arc<std::sync::atomic::AtomicBool>,
}

impl JailBackend for BaselineBackend {
fn implicit_grants(&self) -> Vec<std::path::PathBuf> {
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<std::process::Child> {
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<std::sync::atomic::AtomicBool>,
}

impl JailBackend for BaselineBackend {
fn implicit_grants(&self) -> Vec<std::path::PathBuf> {
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<std::process::Child> {
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();
Expand Down
1 change: 1 addition & 0 deletions crates/tinybox-jail/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/tinybox-jail/src/registry_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
pub(crate) fn canonicalize_missing(path: &Path) -> io::Result<PathBuf> {
if path
.components()
.any(|part| matches!(part, Component::ParentDir))
Expand Down
Loading
Loading