Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion crates/tinybox-bus/src/event_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ fn process_events_keep_their_correlation_and_signal_wire_shape() -> Result<(), s
);
assert_eq!(names::PROCESS_EVENT, "ProcessEvent");
assert_eq!(SIGNALS, &["ProcessEvent"]);
assert_eq!(CONTRACT_VERSION, (2, 4));
assert_eq!(CONTRACT_VERSION, (2, 5));
Ok(())
}

Expand Down
28 changes: 28 additions & 0 deletions crates/tinybox-bus/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ pub struct NativeJailConfig {
/// Optional hard resource limits; omitted for compatibility with older requests.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<crate::ResourceLimits>,
/// File or directory names the child may neither read nor write wherever
/// they appear (a project's `.npmrc`, say), where [`Self::deny_read`] takes
/// exact paths. Each is one path component: not empty, not `.` or `..`, and
/// without a separator, quote or NUL. Only a backend that can deny by name
/// (Seatbelt) admits a request carrying any; every other backend refuses
/// it rather than ignoring it. Added in contract 2.5; empty by default.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deny_read_names: Vec<String>,
}

/// The most restrictive request: no grants beyond the root, no network, no
/// child processes, every constraint enforced and the process-isolation floor
/// required. A host builds on it with `..NativeJailConfig::default()`, so a
/// field added to the contract later does not break its code.
impl Default for NativeJailConfig {
fn default() -> Self {
Self {
read_only: Vec::new(),
read_write: Vec::new(),
allow_net: false,
allow_subprocess: false,
enforcement: JailEnforcement::Enforced,
require_process_isolation: true,
deny_read: Vec::new(),
resources: None,
deny_read_names: Vec::new(),
}
}
}

/// Hard resource limits requested by the contract 2.1 native jail members.
Expand Down
28 changes: 28 additions & 0 deletions crates/tinybox-bus/src/jail_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ fn native_jail_21_resource_subset_is_additive_and_keeps_disk_explicit()
enforcement: JailEnforcement::Enforced,
require_process_isolation: true,
deny_read: vec![],
deny_read_names: Vec::new(),
resources: None,
};
let request = NativeJailRequest {
Expand All @@ -100,3 +101,30 @@ fn native_jail_21_resource_subset_is_additive_and_keeps_disk_explicit()
assert_eq!(crate::NATIVE_JAIL_RESOURCES_VERSION, (2, 1));
Ok(())
}

#[test]
fn read_denials_by_name_are_additive_on_the_wire() -> Result<(), serde_json::Error> {
let mut jail = NativeJailConfig::default();
// Absent when empty, so a 2.4 request decodes and encodes unchanged.
assert!(
serde_json::to_value(&jail)?
.get("deny_read_names")
.is_none()
);
jail.deny_read_names = vec![".npmrc".into()];
let encoded = serde_json::to_value(&jail)?;
assert_eq!(encoded["deny_read_names"], serde_json::json!([".npmrc"]));
assert_eq!(serde_json::from_value::<NativeJailConfig>(encoded)?, jail);
Ok(())
}

#[test]
fn the_default_jail_request_is_the_most_restrictive() {
let jail = NativeJailConfig::default();
assert!(jail.read_only.is_empty() && jail.read_write.is_empty());
assert!(!jail.allow_net && !jail.allow_subprocess);
assert_eq!(jail.enforcement, JailEnforcement::Enforced);
assert!(jail.require_process_isolation);
assert!(jail.deny_read.is_empty() && jail.deny_read_names.is_empty());
assert_eq!(jail.resources, None);
}
6 changes: 4 additions & 2 deletions crates/tinybox-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ pub use files::{
/// the `openshell` sandbox backend for `Create`. 2.4 adds the optional
/// [`CreateRequest::retention`] field, which asks a backend to keep a box for
/// its owner across uses; the `agent37` backend honours it and every other
/// backend refuses it.
pub const CONTRACT_VERSION: (u32, u32) = (2, 4);
/// backend refuses it. 2.5 adds [`NativeJailConfig::deny_read_names`], read
/// denials by file name that only a backend able to deny by name admits, and a
/// most-restrictive `Default` for [`NativeJailConfig`].
pub const CONTRACT_VERSION: (u32, u32) = (2, 5);

/// Minimum contract version required for resource-aware native jail members.
pub const NATIVE_JAIL_RESOURCES_VERSION: (u32, u32) = (2, 1);
Expand Down
13 changes: 7 additions & 6 deletions crates/tinybox-bus/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso
assert_eq!(METHODS[28], "CleanupDocker");
assert_eq!(METHODS[29], "RunDockerWithoutWorkspace");
assert_eq!(METHODS[33], "StartNativeShellExec");
assert_eq!(CONTRACT_VERSION, (2, 4));
assert_eq!(CONTRACT_VERSION, (2, 5));
assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000);
let serialized = serde_json::to_value(&request)?;
assert_eq!(serialized["read_only_rootfs"], false);
Expand Down Expand Up @@ -139,7 +139,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(),
#[test]
fn version_and_capability_wire_snapshot_preserve_contract_compatibility()
-> Result<(), serde_json::Error> {
assert_eq!(CONTRACT_VERSION, (2, 4));
assert_eq!(CONTRACT_VERSION, (2, 5));
assert!(is_compatible(CONTRACT_VERSION));
assert!(!is_compatible((1, 21)));
assert!(!is_compatible((1, 8)));
Expand All @@ -157,7 +157,8 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility()
assert!(!is_compatible((2, 1)));
assert!(!is_compatible((2, 2)));
assert!(!is_compatible((2, 3)));
assert!(is_compatible((2, 4)));
assert!(!is_compatible((2, 4)));
assert!(is_compatible((2, 5)));
assert!(!supports_native_jail_resources((2, 0)));
assert!(supports_native_jail_resources((2, 1)));
assert!(supports_native_jail_resources((2, 2)));
Expand All @@ -167,7 +168,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility()
exec_backends: Vec::new(),
spawn_backends: Vec::new(),
};
let snapshot = serde_json::json!({"contract_version":[2,4],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]});
let snapshot = serde_json::json!({"contract_version":[2,5],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]});
assert_eq!(serde_json::to_value(&capabilities)?, snapshot);
assert_eq!(
serde_json::from_value::<ModuleCapabilities>(snapshot)?,
Expand All @@ -186,7 +187,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() {
.count(),
1
);
assert_eq!(CONTRACT_VERSION, (2, 4));
assert_eq!(CONTRACT_VERSION, (2, 5));
}

#[test]
Expand All @@ -199,7 +200,7 @@ fn shell_analysis_wire_shape_contains_only_structural_facts() -> Result<(), serd

#[test]
fn contract_two_drops_command_classification_but_keeps_structural_shell_analysis() {
assert_eq!(CONTRACT_VERSION, (2, 4));
assert_eq!(CONTRACT_VERSION, (2, 5));
assert!(!METHODS.contains(&"AnalyzeCommand"));
assert!(METHODS.contains(&"AnalyzeShell"));
assert!(METHODS.contains(&"RunNativeShell"));
Expand Down
1 change: 1 addition & 0 deletions crates/tinybox-bus/src/stdio_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fn jail() -> NativeJailConfig {
enforcement: crate::JailEnforcement::Enforced,
require_process_isolation: true,
deny_read: Vec::new(),
deny_read_names: Vec::new(),
resources: None,
}
}
Expand Down
13 changes: 13 additions & 0 deletions crates/tinybox-jail/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ pub struct Jail {
/// 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>,
/// File or directory names the child may neither read nor write wherever
/// they appear. Only a backend that
/// [`denies_by_name`](JailBackend::denies_by_name) can enforce them; the
/// spawn helpers refuse a jail carrying any on every other backend.
pub deny_read_names: Vec<String>,
/// Optional resource values to apply where the backend supports them.
///
/// These values are defaults, not hard requirements. Use
Expand Down Expand Up @@ -61,6 +66,7 @@ impl Jail {
read_only: Vec::new(),
read_write: Vec::new(),
deny_read: Vec::new(),
deny_read_names: Vec::new(),
resources: None,
hard_requirements: Vec::new(),
allow_net: true,
Expand Down Expand Up @@ -385,6 +391,13 @@ pub trait JailBackend: Send + Sync {
false
}

/// Whether [`Jail::deny_read_names`] is enforced: names denied wherever
/// they appear, not only at exact paths. Only Seatbelt can (a regex rule);
/// path-based allow-list backends keep the default `false`.
fn denies_by_name(&self) -> bool {
false
}

/// Refuses a [`Jail::deny_read`] entry this backend's carve cannot hold.
/// Callers run it before admitting a jail; the default refuses nothing.
///
Expand Down
17 changes: 17 additions & 0 deletions crates/tinybox-jail/src/jail_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,20 @@ fn nested_denial_refuses_only_paths_deeper_than_a_direct_child() {
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{nested}");
}
}

#[test]
fn a_backend_that_cannot_deny_by_name_refuses_a_name_denial() {
let root = tempfile::tempdir().expect("tempdir");
let mut jail = Jail::new(root.path(), "names");
jail.deny_read_names = vec![".npmrc".into()];
let command = std::process::Command::new("/bin/true");

let error =
crate::spawn_owned_captured_with(&crate::NoopBackend, &jail, command).expect_err("refused");

assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
assert!(
error.to_string().contains("cannot deny paths by name"),
"{error}"
);
}
19 changes: 19 additions & 0 deletions crates/tinybox-jail/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result<Child> {
jail.canonicalize()?;
let backend = default_backend();
require_resources(backend.as_ref(), &jail)?;
require_name_denials(backend.as_ref(), &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
backend.spawn(&jail, cmd)
}
Expand All @@ -139,6 +140,7 @@ pub fn spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> std::
let mut jail = jail.clone();
jail.canonicalize()?;
require_resources(backend, &jail)?;
require_name_denials(backend, &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
backend.spawn(&jail, cmd)
}
Expand All @@ -155,6 +157,7 @@ pub fn spawn_captured_with(
let mut jail = jail.clone();
jail.canonicalize()?;
require_resources(backend, &jail)?;
require_name_denials(backend, &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
backend.spawn_captured(&jail, cmd)
}
Expand Down Expand Up @@ -194,6 +197,7 @@ fn checked_owned_jail(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result
let mut jail = jail.clone();
jail.canonicalize()?;
require_resources(backend, &jail)?;
require_name_denials(backend, &jail)?;
jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?;
Ok(jail)
}
Expand All @@ -219,6 +223,21 @@ pub fn spawn_owned_captured_with_env(
spawn_owned_captured_with_backend_and_env(default_owned_backend().as_ref(), jail, cmd, env)
}

/// Refuse a jail denying paths by name on a backend that cannot: a name it
/// silently ignored would leave every copy of that file readable.
fn require_name_denials(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result<()> {
if jail.deny_read_names.is_empty() || backend.denies_by_name() {
return Ok(());
}
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"jail backend `{}` cannot deny paths by name",
backend.name()
),
))
}

fn require_resources(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result<()> {
backend.require_resources(jail).map_err(|error| {
let kind = match error {
Expand Down
51 changes: 51 additions & 0 deletions crates/tinybox-jail/src/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ impl JailBackend for SeatbeltBackend {
true
}

fn denies_by_name(&self) -> bool {
true
}

fn validate_carved_denials(&self, jail: &Jail) -> std::io::Result<()> {
validate_denials(jail)
}
Expand Down Expand Up @@ -241,6 +245,40 @@ fn render_profile(jail: &Jail) -> String {
out.push_str(")\n");
}

// A writable grant inside a denied path is re-opened after the deny block:
// Seatbelt applies the last matching rule, so an allow here carves the
// grant out of the denial. Admission refuses a host's writable grant under
// a denial, so in practice this is the per-call scratch the native shell
// adds after admission (`TMPDIR`), which would otherwise be unusable
// whenever the host denies the folder holding its state directory (#89).
let carved: Vec<&std::path::PathBuf> = jail
.read_write
.iter()
.filter(|granted| {
jail.deny_read
.iter()
.any(|denied| granted.starts_with(denied) && *granted != denied)
})
.collect();
if !carved.is_empty() {
out.push_str("(allow file-read* file-write*\n");
for path in carved {
let _ = writeln!(out, " (subpath \"{}\")", escape(&path.to_string_lossy()));
}
out.push_str(")\n");
}

// Names denied wherever they appear: the file itself and, for a
// directory, everything under it. Last, so even a carved-out scratch
// cannot hold a readable `.npmrc`.
if !jail.deny_read_names.is_empty() {
out.push_str("(deny file-read* file-write*\n");
for name in &jail.deny_read_names {
let _ = writeln!(out, " (regex #\"/{}(/|$)\")", regex_escape(name));
}
out.push_str(")\n");
}

// `read_only` is informational on macOS — reads are already allowed
// by `(allow default)`. We keep the field on `Jail` because Landlock
// and AppContainer use it, and it lets callers express intent
Expand All @@ -254,6 +292,19 @@ fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}

/// `name` as a literal inside a Seatbelt regex. The module refuses names with
/// a quote, separator or NUL before they get here.
fn regex_escape(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for character in name.chars() {
if r"\.^$|?*+()[]{}".contains(character) {
out.push('\\');
}
out.push(character);
}
out
}

#[cfg(test)]
#[path = "macos_tests.rs"]
mod tests;
Loading
Loading