Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e27501a
fix(detect): handle missing os-release file gracefully
senamakel Oct 3, 2026
f4f1d6d
chore: files changed crates/tinybox-jail/src/detect.rs,crates/tinybox…
senamakel Oct 3, 2026
20ae8d6
test: add tests for jail detection and linux module
senamakel Oct 3, 2026
0099e92
fix(jail): restore missing `use std::sync::Arc` import
senamakel Oct 3, 2026
be3d687
fix(detect): handle missing os-release gracefully on Linux
senamakel Oct 3, 2026
618d9df
fix(linux): handle empty cgroup path in jail setup
senamakel Oct 3, 2026
224d147
test: add test for landlock spawn being unsupported without feature flag
senamakel Oct 3, 2026
183bfba
fix(detect): handle missing sysfs on Linux without panicking
senamakel Oct 3, 2026
27d4efa
fix(macos): use writeln macro for profile rendering
senamakel Oct 3, 2026
1c84e11
test(spawn): suppress unused_mut warning on non-Linux
senamakel Oct 3, 2026
f73d050
fix(tinybox-jail): add README with usage and design overview
senamakel Oct 3, 2026
72ecb37
fix(docs): correct jail backend table for Windows and Linux
senamakel Oct 3, 2026
e083e35
docs(tinybox-jail): format AppContainer name in documentation table
senamakel Oct 3, 2026
c5520b0
fix(landlock): add /run/systemd/resolve to system read paths
senamakel Oct 3, 2026
87e0538
fix(tests): reformat assertion to comply with line length
senamakel Oct 3, 2026
7d32aa3
refactor(jail): split candidates into per-platform functions
senamakel Oct 3, 2026
2c62595
fix(detect): backtick-quote AppContainer in doc comment
senamakel Oct 3, 2026
156d500
feat(jail): extract backend selection and launcher preparation for te…
senamakel Oct 3, 2026
ff43024
refactor(linux): extract enforcement check and injectable support flag
senamakel Oct 3, 2026
1d8c8a5
refactor(jail): make imp functions testable and move test module
senamakel Oct 3, 2026
a728127
fix(linux): pass RulesetStatus by reference in check_enforcement
senamakel Oct 3, 2026
ccbd30d
test(linux_imp, macos): extract default-name assertion and add env-cl…
senamakel Oct 3, 2026
97115ef
docs(macos): document environment forwarding and env_clear behaviour
senamakel Oct 3, 2026
5937232
test(macos): fix environment variable removal assertion
senamakel Oct 3, 2026
34e8bd6
test(linux_imp_tests): reject partially enforced rulesets in test
senamakel Oct 3, 2026
8651b29
chore(tinybox-jail): fail closed on partially enforced Landlock rulesets
senamakel Oct 3, 2026
e5b868f
fix(landlock): restrict file grants to file-only access rights
senamakel Oct 3, 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
6 changes: 4 additions & 2 deletions crates/tinybox-jail/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ categories.workspace = true
publish = false

[features]
# Enables the Linux Landlock backend. Without it `LandlockBackend` reports
# itself unavailable and default spawning returns an unsupported error.
# On by default so a plain dependency is actually confined on Linux. Without
# the feature `LandlockBackend` reports itself unavailable and `spawn` returns
# `Unsupported` (it never runs the command unconfined).
default = ["landlock"]
landlock = ["dep:landlock"]

[dependencies]
Expand Down
45 changes: 31 additions & 14 deletions crates/tinybox-jail/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# cwd_jail

Directory-jail facade. Given a declarative description of a workspace
(`Jail`), it spawns a child through an available sandbox backend. Platform
backends are currently disabled until they can satisfy the workspace safety
policy and enforce the declared jail contract. The default backend therefore
returns `Unsupported`; callers can explicitly select `NoopBackend` when
unrestricted execution is intended.
(`Jail`), it spawns a child through an available sandbox backend. Linux
(Landlock) and macOS (Seatbelt) backends are compiled and selected by
`pick_backend()`. The Windows AppContainer backend is still not compiled (see
below). When no backend is usable the default backend is `unsupported`
(`is_available() == false`, `spawn` fails with `Unsupported`); callers can
explicitly select `NoopBackend` when unrestricted execution is intended.
It is a per-process complement to the box-level isolation in `tinybox-linux`:
the autonomy gate decides whether a command may run, and `cwd_jail` decides
what filesystem the approved child process sees. It jails the child it
Expand All @@ -19,8 +20,8 @@ spawns, never the core process itself.
root the same access as the root (Landlock rule, Seatbelt `file-write*`
subpath, `AppContainer` ACL), for host-owned scratch such as a per-call
output-capture directory that must not land inside the root.
- Cache the default backend; currently this is an unsupported backend on every
platform while OS implementations are being brought into compliance.
- Cache the default backend: Landlock on Linux kernels that support it,
Seatbelt on macOS, otherwise the `unsupported` backend.
- Spawn a `std::process::Command` inside the jail, canonicalizing `root`
(and the read-only and read/write paths) first so backends never see `..` or symlink
trickery.
Expand All @@ -36,11 +37,11 @@ spawns, never the core process itself.
| --- | --- |
| `crates/tinybox-jail/src/lib.rs` | Module docstring plus the thin facade: `spawn` / `spawn_with` / `default_backend` (cached via `OnceLock`). Re-exports the public surface. |
| `crates/tinybox-jail/src/jail.rs` | Core types: the `Jail` description struct (builder plus `canonicalize`/`canonicalize_or_log`) and the `JailBackend` trait (`name`/`is_available`/`spawn`). |
| `crates/tinybox-jail/src/detect.rs` | `pick_backend()`: returns an unsupported backend until a compliant platform backend is available. |
| `crates/tinybox-jail/src/detect.rs` | `pick_backend()`: first available OS backend, else an unsupported backend that fails closed. |
| `crates/tinybox-jail/src/noop.rs` | `NoopBackend`: no enforcement, plain `Command::spawn`. Always available. |
| `crates/tinybox-jail/src/linux.rs` | Proposed Landlock implementation; currently not compiled or selected. |
| `crates/tinybox-jail/src/macos.rs` | Proposed Seatbelt implementation; currently not compiled or selected. |
| `crates/tinybox-jail/src/windows.rs` | Proposed AppContainer implementation; currently not compiled or selected. |
| `crates/tinybox-jail/src/linux.rs` | Landlock backend (Linux only, `landlock` feature, on by default). Applies the ruleset to a dedicated spawn thread so no `unsafe` `pre_exec` is needed. |
| `crates/tinybox-jail/src/macos.rs` | Seatbelt backend via `sandbox-exec`. Compiled on every host so the profile renderer is unit-tested everywhere; selected only on macOS. |
| `crates/tinybox-jail/src/windows.rs` | AppContainer implementation; **not compiled**: it needs `unsafe` FFI the workspace forbids and cannot return a waitable `std::process::Child` yet. |
| `crates/tinybox-jail/src/registry.rs` | `JailRegistry` and `JailRecord`: multi-jail manager persisted to `index.json`, with atomic-rename writes and containment checks. |
| `crates/tinybox-jail/src/{lib,jail,noop,macos,windows,registry}_tests.rs` | Sibling test suites, each `#[path]`-included from its source file. |

Expand Down Expand Up @@ -123,16 +124,32 @@ not import that module.
returns `io::ErrorKind::Unsupported`. See the TODO in `windows.rs`. The
Windows path is compile-checked but flagged as needing real-hardware
testing.
- macOS forwards only variables explicitly supplied with `Command::env` or
`Command::envs`. The launcher clears the inherited environment because Rust
exposes no getter for `Command::env_clear`; this prevents restoring parent
credentials a caller deliberately removed. Supply required variables such
as `PATH` explicitly. Linux preserves the original command environment.
- macOS stdio is inherited: the Seatbelt wrapper cannot re-apply the
original command's `Stdio` config, so it uses `sandbox-exec` defaults
(inherit). The profile re-allows writes under the canonicalized `root` and
`/private/tmp`, plus the exact `/dev/null` device for shell redirection;
it does not grant `/dev` generally. Callers must canonicalize the root
first (the `spawn` facade does this automatically) or writes inside it may
be denied (for example `/tmp` resolving to `/private/tmp`).
- Linux Landlock runs in `pre_exec` (child-side, after fork), so the parent
keeps its privileges; read-only paths also get `Execute` so the child can
run binaries found there (for example `/usr/bin/sh`).
- Linux Landlock is applied to a short-lived dedicated thread that then
spawns the command; the child inherits the thread's domain and
`no_new_privs`, the caller's thread keeps its privileges. Read-only paths
also get `Execute` so the child can run binaries found there.
- Linux baseline grants (see `SYSTEM_READ_PATHS`, `DEVICE_PATHS` in
`linux.rs`): `/usr /bin /sbin /lib* /etc` read+execute and a few harmless
`/dev` nodes read+write. Everything else is denied unless the `Jail` grants
it: the rest of `$HOME`, `/proc`, `/sys` and `/tmp` included. Grant scratch
space and toolchain caches with `add_read_write` / `add_read_only`.
- On a kernel without Landlock (or a build without the `landlock` feature)
the backend reports unavailable and `spawn` returns `Unsupported`; it never
runs the command unconfined. The availability probe checks basic support;
spawning also rejects partially enforced rulesets with `Unsupported` before
running the child, including older ABIs that cannot restrict truncation.
- Registry containment guard: both `delete` and `jail_for` (used by
`spawn_in`/`spawn_in_with`) refuse to operate on a record whose
canonicalized `dir` is not under the canonicalized `base`, defending
Expand Down
47 changes: 43 additions & 4 deletions crates/tinybox-jail/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ use std::sync::Arc;
use super::jail::{Jail, JailBackend};
use std::process::{Child, Command};

/// Name reported by the backend returned when no OS sandbox is usable.
pub const UNSUPPORTED_BACKEND_NAME: &str = "unsupported";

#[derive(Debug)]
struct UnsupportedBackend;

impl JailBackend for UnsupportedBackend {
fn name(&self) -> &'static str {
"unsupported"
UNSUPPORTED_BACKEND_NAME
}

fn is_available(&self) -> bool {
Expand All @@ -25,11 +28,47 @@ impl JailBackend for UnsupportedBackend {
}
}

/// Picks the strongest available backend, returning an unsupported backend
/// when no OS sandbox works.
/// The OS backends this build knows about, strongest first.
///
/// Windows `AppContainer` is intentionally absent: it cannot hand back a
/// waitable `std::process::Child` yet (see `windows.rs`).
#[cfg(target_os = "linux")]
fn candidates() -> Vec<Arc<dyn JailBackend>> {
vec![Arc::new(crate::linux::LandlockBackend::new())]
}

/// The OS backends this build knows about, strongest first.
#[cfg(target_os = "macos")]
fn candidates() -> Vec<Arc<dyn JailBackend>> {
vec![Arc::new(crate::macos::SeatbeltBackend::new())]
}

/// The OS backends this build knows about, strongest first. None here.
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn candidates() -> Vec<Arc<dyn JailBackend>> {
Vec::new()
}

/// Picks the first available OS backend (Landlock on Linux, Seatbelt on
/// macOS). When none works it logs a warning and returns an unsupported
/// backend whose `is_available` is `false` and whose `spawn` fails with
/// `ErrorKind::Unsupported`. It never silently returns an unconfined backend:
/// a caller that wants to run unconfined must choose `NoopBackend` itself.
#[must_use]
pub fn pick_backend() -> Arc<dyn JailBackend> {
log::warn!("[cwd_jail] no OS sandbox available");
pick_from(candidates())
}

/// Select an available backend from the ordered platform candidates.
fn pick_from(backends: Vec<Arc<dyn JailBackend>>) -> Arc<dyn JailBackend> {
for backend in backends {
if backend.is_available() {
log::debug!("[cwd_jail] selected OS sandbox backend {}", backend.name());
return backend;
}
log::debug!("[cwd_jail] backend {} is not available", backend.name());
}
log::warn!("[cwd_jail] no OS sandbox available; jailed spawns are unsupported");
Arc::new(UnsupportedBackend)
}

Expand Down
55 changes: 54 additions & 1 deletion crates/tinybox-jail/src/detect_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use super::*;
#[test]
fn unavailable_backend_rejects_spawning() {
let backend = UnsupportedBackend;
assert_eq!(backend.name(), "unsupported");
assert_eq!(backend.name(), UNSUPPORTED_BACKEND_NAME);
assert!(!backend.is_available());
let error = backend
.spawn(&Jail::new(".", "unsupported"), Command::new("true"))
Expand All @@ -18,3 +18,56 @@ fn unavailable_backend_rejects_spawning() {
fn backend_detection_returns_a_backend() {
assert_ne!(pick_backend().name().len(), 0);
}

#[test]
fn detection_prefers_the_platform_backend_when_it_works() {
let picked = pick_backend();
let expected = candidates()
.into_iter()
.find(|backend| backend.is_available());
if let Some(backend) = expected {
assert_eq!(picked.name(), backend.name());
assert!(picked.is_available());
} else {
assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME);
assert!(!picked.is_available());
}
}

#[cfg(all(target_os = "linux", feature = "landlock"))]
#[test]
fn linux_with_landlock_selects_landlock_on_a_supporting_kernel() {
if crate::linux::LandlockBackend::new().is_available() {
assert_eq!(pick_backend().name(), "landlock");
}
}

#[test]
fn windows_appcontainer_is_never_a_candidate() {
assert!(
candidates()
.iter()
.all(|backend| backend.name() != "appcontainer")
);
}

#[test]
fn unavailable_candidates_are_skipped_and_empty_candidates_fail_closed() {
for candidates in [
vec![],
vec![Arc::new(UnsupportedBackend) as Arc<dyn JailBackend>],
] {
let picked = pick_from(candidates);
assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME);
assert!(!picked.is_available());
}
}

#[test]
fn selection_uses_the_first_available_candidate() {
let picked = pick_from(vec![
Arc::new(UnsupportedBackend),
Arc::new(crate::noop::NoopBackend),
]);
assert_eq!(picked.name(), crate::noop::NOOP_BACKEND_NAME);
}
21 changes: 17 additions & 4 deletions crates/tinybox-jail/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
//!
//! | OS | Backend | Mechanism |
//! |---------|---------------|--------------------------------------------|
//! | Linux | landlock | Kernel 5.13+ LSM, applied in `pre_exec` |
//! | Linux | landlock | Kernel 5.13+ LSM, applied on a spawn thread |
//! | macOS | seatbelt | `sandbox-exec -p '<profile>' …` |
//! | Windows | appcontainer | `CreateAppContainerProfile` + `STARTUPINFOEX` |
//! | Windows | (not compiled)| `AppContainer`, pending a `Child` bridge |
//! | other | unsupported | Spawning is rejected |
//!
//! The Windows backend is not compiled yet (see `windows.rs`); on Windows the
//! default backend is `unsupported` and a host must opt into `NoopBackend`.
//!
//! ## Quick start
//!
//! ```ignore
Expand Down Expand Up @@ -50,10 +53,20 @@ pub mod jail;
pub mod noop;
pub mod registry;

// Platform backends are intentionally not compiled until they can preserve
// the workspace unsafe-code policy and enforce the public jail contract.
// Platform backends. Linux (Landlock) is compiled on Linux only. The Seatbelt
// module is plain `std` (it shells out to `sandbox-exec`), so it compiles
// everywhere and its profile renderer is unit-tested on every host; it is only
// *selected* on macOS. The Windows AppContainer module (`windows.rs`) is
// deliberately not compiled: it needs `unsafe` FFI the workspace forbids and
// cannot yet return a waitable `std::process::Child`.
#[cfg(target_os = "linux")]
pub mod linux;
pub mod macos;

pub use jail::{Jail, JailBackend};
#[cfg(target_os = "linux")]
pub use linux::LandlockBackend;
pub use macos::SeatbeltBackend;
pub use noop::{NOOP_BACKEND_NAME, NoopBackend};
pub use registry::{JailRecord, JailRegistry};

Expand Down
Loading
Loading