From 262cdfa0ce07bf1fd5c5f5a314d8a70a7057f91e Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:55:08 +0530 Subject: [PATCH 1/4] Keep a writable grant inside a Seatbelt denial usable render_profile emitted the deny_read block as the profile's last rule, so it overrode every write grant. The native shell adds its per-call scratch (TMPDIR) to read_write after admission, so a host that denied the folder holding its state directory, such as ~/.openhuman, left every jailed command without a usable TMPDIR. After the deny block, re-allow reads and writes for each writable grant that sits strictly inside a denied path. Seatbelt applies the last matching rule, which a macOS probe confirmed: an allow after a deny re-opens the subpath and leaves the rest denied. Admission already refuses a host's writable grant under a denial, so in practice this carves out only the scratch. Refs #89 --- crates/tinybox-jail/src/macos.rs | 23 ++++++++++ crates/tinybox-jail/src/macos_tests.rs | 58 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/crates/tinybox-jail/src/macos.rs b/crates/tinybox-jail/src/macos.rs index 455c2935..7ecb5f11 100644 --- a/crates/tinybox-jail/src/macos.rs +++ b/crates/tinybox-jail/src/macos.rs @@ -241,6 +241,29 @@ 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"); + } + // `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 diff --git a/crates/tinybox-jail/src/macos_tests.rs b/crates/tinybox-jail/src/macos_tests.rs index 9469b2de..4f4dc227 100644 --- a/crates/tinybox-jail/src/macos_tests.rs +++ b/crates/tinybox-jail/src/macos_tests.rs @@ -44,6 +44,64 @@ fn profile_denies_reads_and_writes_of_denied_paths_last() { assert!(SeatbeltBackend::new().carves_denials()); } +#[test] +fn a_writable_grant_inside_a_denied_path_is_carved_out_after_the_denial() { + let mut jail = Jail::new("/Users/a/project", "x"); + jail.deny_read = vec!["/Users/a/.openhuman".into()]; + jail.read_write = vec![ + "/Users/a/.openhuman/workspace/artifacts/sandbox-scratch/shell-1".into(), + "/Users/a/cache".into(), + ]; + let p = render_profile(&jail); + let denial = p.find("(deny file-read* file-write*").expect("denial"); + let carve = p.rfind("(allow file-read* file-write*").expect("carve-out"); + // After the denial, so the last matching rule re-opens the scratch. + assert!(carve > denial); + assert!(p[carve..].contains("sandbox-scratch/shell-1")); + // Only grants inside a denial are carved; the rest need no carve-out. + assert!(!p[carve..].contains("/Users/a/cache")); + // Without a grant inside a denial there is no carve-out at all. + jail.read_write = vec!["/Users/a/cache".into()]; + assert!(!render_profile(&jail).contains("(allow file-read* file-write*")); +} + +/// Live: the per-call scratch under a denied state folder stays readable and +/// writable, while the rest of the folder stays denied (#89). +#[test] +fn a_scratch_inside_a_denied_folder_stays_usable_while_the_folder_is_denied() { + let backend = SeatbeltBackend::new(); + if !backend.is_available() { + return; + } + let workspace = tempfile::tempdir().expect("tempdir"); + let workspace = workspace.path().canonicalize().expect("canonical root"); + let state_dir = tempfile::tempdir().expect("tempdir"); + let state = state_dir.path().canonicalize().expect("canonical state"); + let scratch = state.join("artifacts").join("scratch"); + fs::create_dir_all(&scratch).expect("mkdir"); + fs::write(state.join("secret.txt"), "state-secret").expect("write"); + let mut jail = Jail::new(&workspace, "test.carve"); + jail.read_write = vec![scratch.clone()]; + jail.deny_read = vec![state.clone()]; + let mut cmd = Command::new("/bin/sh"); + cmd.args([ + "-c", + "echo carved > \"$1/f\" && cat \"$1/f\" && ! cat \"$2/secret.txt\"", + "sh", + ]) + .arg(&scratch) + .arg(&state) + .current_dir(&workspace); + + let child = backend.spawn_captured(&jail, cmd).expect("spawn"); + let output = child.wait_with_output().expect("wait"); + + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "carved"); + assert!(!stdout.contains("state-secret")); +} + #[test] fn escape_handles_backslash_and_quote() { assert_eq!(escape("a\\b"), "a\\\\b"); From eba5f8fdf77cbd1b0ab6d11aa811f4ea772bcbd1 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:58:39 +0530 Subject: [PATCH 2/4] Deny paths by name in Seatbelt jails (contract 2.5) deny_read takes exact paths, so a host could not deny a credential file name wherever it appears, such as a project's .npmrc (#89). - NativeJailConfig gains deny_read_names (contract 2.5, empty and omitted by default): one path component each, validated at admission and counted against MAX_NATIVE_JAIL_GRANTS. - JailBackend gains denies_by_name (default false). Seatbelt returns true and renders each name as a regex deny of / and everything below it, as the profile's last rule, after the scratch carve-out. A macOS probe confirmed sandbox-exec honours a regex denial at any depth. - Every other backend refuses such a jail instead of ignoring it, both in the module's admission (with the reason) and in the tinybox_jail spawn helpers. - NativeJailConfig gets a most-restrictive Default, so a host building requests with ..NativeJailConfig::default() keeps compiling when the contract grows a field. Every literal in this workspace gains the new field. Closes #89 --- crates/tinybox-bus/src/event_tests.rs | 2 +- crates/tinybox-bus/src/jail.rs | 28 +++++++ crates/tinybox-bus/src/jail_tests.rs | 24 ++++++ crates/tinybox-bus/src/lib.rs | 6 +- crates/tinybox-bus/src/lib_tests.rs | 11 +-- crates/tinybox-bus/src/stdio_tests.rs | 1 + crates/tinybox-jail/src/jail.rs | 13 +++ crates/tinybox-jail/src/jail_tests.rs | 15 ++++ crates/tinybox-jail/src/lib.rs | 16 ++++ crates/tinybox-jail/src/macos.rs | 28 +++++++ crates/tinybox-jail/src/macos_tests.rs | 53 ++++++++++++ .../src/tinybus_module/mod_tests.rs | 2 + .../src/tinybus_module/native_jail.rs | 29 ++++++- .../src/tinybus_module/native_jail_tests.rs | 81 +++++++++++++++++++ .../tinybus_module/native_shell/mod_tests.rs | 1 + .../tinybus_module/resources_stdio_tests.rs | 1 + .../tinybus_module/resources_stream_tests.rs | 2 + .../src/tinybus_module/stdio_tests.rs | 1 + crates/tinybox-module/tests/module_e2e.rs | 2 + docs/specs/contract-2-5.md | 22 +++++ 20 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 docs/specs/contract-2-5.md diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs index 31eba38b..664941ab 100644 --- a/crates/tinybox-bus/src/event_tests.rs +++ b/crates/tinybox-bus/src/event_tests.rs @@ -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(()) } diff --git a/crates/tinybox-bus/src/jail.rs b/crates/tinybox-bus/src/jail.rs index c4d686a8..f06c73e7 100644 --- a/crates/tinybox-bus/src/jail.rs +++ b/crates/tinybox-bus/src/jail.rs @@ -36,6 +36,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, + /// 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, +} + +/// 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. diff --git a/crates/tinybox-bus/src/jail_tests.rs b/crates/tinybox-bus/src/jail_tests.rs index 4551ed5b..ebe81b35 100644 --- a/crates/tinybox-bus/src/jail_tests.rs +++ b/crates/tinybox-bus/src/jail_tests.rs @@ -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 { @@ -100,3 +101,26 @@ 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::(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); +} diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 1ddb23ba..392a662b 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -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); diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 13fd7d90..080cf342 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -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); @@ -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))); @@ -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))); @@ -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] @@ -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")); diff --git a/crates/tinybox-bus/src/stdio_tests.rs b/crates/tinybox-bus/src/stdio_tests.rs index 7630e7d5..2a9dc997 100644 --- a/crates/tinybox-bus/src/stdio_tests.rs +++ b/crates/tinybox-bus/src/stdio_tests.rs @@ -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, } } diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 3b9aa4dc..a9ca1d41 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -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, + /// 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, /// Optional resource values to apply where the backend supports them. /// /// These values are defaults, not hard requirements. Use @@ -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, @@ -350,6 +356,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. /// diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index f46d08f4..da7c8e92 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -320,3 +320,18 @@ 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) + .err() + .expect("refused"); + + assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); + assert!(error.to_string().contains("cannot deny paths by name"), "{error}"); +} diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index eae82bed..ed9c981c 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -120,6 +120,7 @@ pub fn spawn(jail: &Jail, cmd: Command) -> std::io::Result { 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) } @@ -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) } @@ -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) } @@ -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) } @@ -219,6 +223,18 @@ 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 { diff --git a/crates/tinybox-jail/src/macos.rs b/crates/tinybox-jail/src/macos.rs index 7ecb5f11..96e16b0e 100644 --- a/crates/tinybox-jail/src/macos.rs +++ b/crates/tinybox-jail/src/macos.rs @@ -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) } @@ -264,6 +268,17 @@ fn render_profile(jail: &Jail) -> String { 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 @@ -277,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; diff --git a/crates/tinybox-jail/src/macos_tests.rs b/crates/tinybox-jail/src/macos_tests.rs index 4f4dc227..0c7caa44 100644 --- a/crates/tinybox-jail/src/macos_tests.rs +++ b/crates/tinybox-jail/src/macos_tests.rs @@ -102,6 +102,59 @@ fn a_scratch_inside_a_denied_folder_stays_usable_while_the_folder_is_denied() { assert!(!stdout.contains("state-secret")); } +#[test] +fn names_are_denied_by_a_regex_after_every_allow() { + let mut jail = Jail::new("/Users/a/project", "x"); + assert!(!render_profile(&jail).contains("regex")); + jail.deny_read = vec!["/Users/a/.openhuman".into()]; + jail.read_write = vec!["/Users/a/.openhuman/scratch".into()]; + jail.deny_read_names = vec![".npmrc".into(), "a+b".into()]; + let p = render_profile(&jail); + let names = p.find("(regex #\"/\\.npmrc(/|$)\")").expect("name rule"); + assert!(p.contains("(regex #\"/a\\+b(/|$)\")"), "{p}"); + // Last of all, so a carved-out scratch cannot hold a readable `.npmrc`. + assert!(names > p.rfind("(allow file-read* file-write*").expect("carve-out")); + assert!(SeatbeltBackend::new().denies_by_name()); +} + +#[test] +fn regex_escape_quotes_every_metacharacter() { + assert_eq!(regex_escape(".npmrc"), "\\.npmrc"); + assert_eq!(regex_escape("a(b)[c]{d}^$|?*+"), "a\\(b\\)\\[c\\]\\{d\\}\\^\\$\\|\\?\\*\\+"); + assert_eq!(regex_escape("plain"), "plain"); +} + +/// Live: a name is denied at any depth and as a directory, a sibling is not. +#[test] +fn a_denied_name_is_unreadable_wherever_it_appears() { + let backend = SeatbeltBackend::new(); + if !backend.is_available() { + return; + } + let root = tempfile::tempdir().expect("tempdir"); + let root = root.path().canonicalize().expect("canonical root"); + fs::create_dir_all(root.join("a").join("b")).expect("mkdir"); + fs::write(root.join(".npmrc"), "top-token").expect("write"); + fs::write(root.join("a").join("b").join(".npmrc"), "deep-token").expect("write"); + fs::write(root.join("a").join("other.txt"), "visible").expect("write"); + let mut jail = Jail::new(&root, "test.names"); + jail.deny_read_names = vec![".npmrc".into()]; + let mut cmd = Command::new("/bin/sh"); + cmd.args([ + "-c", + "cat a/other.txt; ! cat .npmrc 2>/dev/null && ! cat a/b/.npmrc 2>/dev/null", + ]) + .current_dir(&root); + + let child = backend.spawn_captured(&jail, cmd).expect("spawn"); + let output = child.wait_with_output().expect("wait"); + + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "visible"); + assert!(!stdout.contains("token")); +} + #[test] fn escape_handles_backslash_and_quote() { assert_eq!(escape("a\\b"), "a\\\\b"); diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index d4cae5b0..8f5eb9fc 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -270,6 +270,7 @@ async fn new_module_operations_dispatch_and_preserve_validation_errors() -> tiny enforcement: tinybox_bus::JailEnforcement::Declared, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }, ) @@ -1760,6 +1761,7 @@ async fn stdio_members_refuse_unknown_handles_without_starting_anything() -> tin enforcement: JailEnforcement::Enforced, require_process_isolation: true, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }, }; diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 2d7c0799..b514fbf0 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -19,11 +19,27 @@ pub(super) fn prepare( if !cfg!(unix) || !backend.is_available() || backend.name() == tinybox_jail::NOOP_BACKEND_NAME { return Err(unavailable()); } - if config.read_only.len() + config.read_write.len() + config.deny_read.len() + if config.read_only.len() + + config.read_write.len() + + config.deny_read.len() + + config.deny_read_names.len() > tinybox_bus::MAX_NATIVE_JAIL_GRANTS { return Err(invalid()); } + if !config.deny_read_names.is_empty() && !backend.denies_by_name() { + return Err(super::resources::failure( + tinybox_bus::UNSUPPORTED_OPERATION, + &format!( + "requested native jail enforcement is unavailable: backend `{}` cannot deny \ + paths by name", + backend.name() + ), + )); + } + if !config.deny_read_names.iter().all(|name| is_plain_name(name)) { + return Err(invalid()); + } // Before anything is canonicalized: a link the jailed program planted // (`/vendor -> `) is only visible in the raw spellings. { @@ -56,6 +72,7 @@ pub(super) fn prepare( jail.read_only = grants(&config.read_only)?; jail.read_write = grants(&config.read_write)?; jail.deny_read = denials(&config.deny_read)?; + jail.deny_read_names.clone_from(&config.deny_read_names); configure_resources(&mut jail, config)?; // A denial over the root or a writable grant would leave the command no // workspace; a denial nested inside a writable grant is safe only for a @@ -244,6 +261,16 @@ fn canonicalize_prospective(path: &Path) -> std::io::Result fn overlaps(left: &Path, right: &Path) -> bool { left.starts_with(right) || right.starts_with(left) } +/// Whether `name` is one path component a profile rule can match exactly: not +/// empty, not `.` or `..`, bounded, and without a separator, quote or NUL. +fn is_plain_name(name: &str) -> bool { + !name.is_empty() + && name != "." + && name != ".." + && name.len() <= tinybox_bus::MAX_FILE_PATH_BYTES + && !name.contains(['/', '\\', '"', '\0']) +} + fn invalid() -> Error { super::resources::failure( tinybox_bus::INVALID_FILE_PATH, 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 ebbab801..ff494051 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -26,6 +26,9 @@ impl JailBackend for Backend { fn carves_denials(&self) -> bool { self.name == "seatbelt" } + fn denies_by_name(&self) -> bool { + self.name == "seatbelt" + } fn validate_carved_denials(&self, jail: &Jail) -> std::io::Result<()> { if self.carves_denials() { tinybox_jail::SeatbeltBackend::new().validate_carved_denials(jail) @@ -85,6 +88,7 @@ fn config() -> NativeJailConfig { enforcement: JailEnforcement::Enforced, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, } } @@ -1563,3 +1567,80 @@ fn a_symlink_planted_in_the_writable_root_cannot_redirect_a_grant() assert!(prepare(root.path(), &outside, &command, &backend).is_ok()); Ok(()) } + +#[cfg(unix)] +fn name_request( + root: &std::path::Path, + names: &[&str], +) -> (NativeJailConfig, tinybox_core::ExecRequest) { + let mut config = config(); + config.allow_net = true; + config.allow_subprocess = true; + config.deny_read_names = names.iter().map(ToString::to_string).collect(); + let mut command = tinybox_core::ExecRequest::new(["irrelevant"]); + command.cwd = Some(root.into()); + (config, command) +} + +#[cfg(unix)] +#[test] +fn a_name_denial_is_refused_by_a_backend_that_cannot_deny_by_name() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let (config, command) = name_request(root.path(), &[".npmrc"]); + let backend = Backend { + available: true, + suitable: true, + name: "fixture", + full: true, + }; + + match prepare(root.path(), &config, &command, &backend) { + Err(Error::MethodFailed { name, message }) => { + assert_eq!(name, tinybox_bus::UNSUPPORTED_OPERATION); + assert!(message.contains("cannot deny paths by name"), "{message}"); + } + other => panic!("expected a refusal, got {:?}", other.map(|jail| jail.deny_read_names)), + } + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_name_denial_reaches_the_jail_on_a_backend_that_denies_by_name() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let (config, command) = name_request(root.path(), &[".npmrc", "credentials.json"]); + let backend = Backend { + available: true, + suitable: true, + name: "seatbelt", + full: true, + }; + + let jail = prepare(root.path(), &config, &command, &backend)?; + + assert_eq!(jail.deny_read_names, [".npmrc", "credentials.json"]); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn a_name_that_is_not_one_plain_component_is_an_invalid_path() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let backend = Backend { + available: true, + suitable: true, + name: "seatbelt", + full: true, + }; + for bad in ["", ".", "..", "a/b", "a\\b", "a\"b", "a\0b"] { + let (config, command) = name_request(root.path(), &[bad]); + match prepare(root.path(), &config, &command, &backend) { + Err(error) => assert_eq!(error.wire_name(), tinybox_bus::INVALID_FILE_PATH, "{bad:?}"), + Ok(_) => panic!("{bad:?} was admitted"), + } + } + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index 485dbb02..712c1b95 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -138,6 +138,7 @@ async fn native_shell_jail_adds_owned_scratch_and_refuses_unsupported_constraint enforcement: tinybox_bus::JailEnforcement::Enforced, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }); let shell = ShellContext::prepare_with_backend( diff --git a/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs index 8dda74d3..a649fcb1 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stdio_tests.rs @@ -70,6 +70,7 @@ fn jail_config() -> tinybox_bus::NativeJailConfig { enforcement: tinybox_bus::JailEnforcement::Enforced, require_process_isolation: true, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, } } diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 30863208..bee54a81 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -361,6 +361,7 @@ async fn jailed_start_rejects_missing_grants_without_consuming_reservation() -> enforcement: tinybox_bus::JailEnforcement::Declared, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }; assert!( @@ -1547,6 +1548,7 @@ async fn start_resource_descendant( enforcement: tinybox_bus::JailEnforcement::Enforced, require_process_isolation: true, deny_read: vec![], + deny_read_names: Vec::new(), resources: None, }, resources: Some(tinybox_bus::NativeJailResourceRequest { diff --git a/crates/tinybox-module/src/tinybus_module/stdio_tests.rs b/crates/tinybox-module/src/tinybus_module/stdio_tests.rs index 215b54bb..bda8c96f 100644 --- a/crates/tinybox-module/src/tinybus_module/stdio_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/stdio_tests.rs @@ -35,6 +35,7 @@ fn request() -> StdioStartRequest { enforcement: JailEnforcement::Declared, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }, } diff --git a/crates/tinybox-module/tests/module_e2e.rs b/crates/tinybox-module/tests/module_e2e.rs index 28c50fe3..79685a42 100644 --- a/crates/tinybox-module/tests/module_e2e.rs +++ b/crates/tinybox-module/tests/module_e2e.rs @@ -671,6 +671,7 @@ async fn verify_native_jail( enforcement: JailEnforcement::Enforced, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }; let status: tinybox_bus::JailStatus = proxy.call("JailStatus", ()).await?; @@ -809,6 +810,7 @@ async fn verify_docker_rejects_local_jail( enforcement: JailEnforcement::Declared, require_process_isolation: false, deny_read: Vec::new(), + deny_read_names: Vec::new(), resources: None, }, ), diff --git a/docs/specs/contract-2-5.md b/docs/specs/contract-2-5.md new file mode 100644 index 00000000..b0fc08e7 --- /dev/null +++ b/docs/specs/contract-2-5.md @@ -0,0 +1,22 @@ +# TinyBox contract 2.5 + +Contract 2.5 adds one optional field to `NativeJailConfig` and a `Default` for +it, without changing any 2.4 request or reply shape: + +| Addition | What a host may rely on | +| --- | --- | +| `NativeJailConfig.deny_read_names` | `Vec`, empty and omitted by default. File or directory names the child may neither read nor write wherever they appear, where `deny_read` takes exact paths. Only a backend that denies by name (Seatbelt, through a regex rule) admits a request carrying any; every other backend refuses it with `UNSUPPORTED_OPERATION` rather than ignoring it. | +| `impl Default for NativeJailConfig` | The most restrictive request: no grants, no network, no child processes, `Enforced`, the process-isolation floor required, no denials or limits. A host that builds requests with `..NativeJailConfig::default()` keeps compiling when the contract adds a field. | + +- Each name is one path component: not empty, not `.` or `..`, at most + `MAX_FILE_PATH_BYTES`, and without `/`, `\`, `"` or NUL. Anything else is + refused with `INVALID_FILE_PATH`. Names count against `MAX_NATIVE_JAIL_GRANTS` + together with the grants and path denials. +- Seatbelt denies `/` and everything below it, as the profile's last + rule, so it holds even inside a writable grant carved out of a path denial. +- The `JailBackend::denies_by_name` trait method (default `false`) is what a + backend overrides to accept them. The `tinybox_jail` spawn helpers refuse a + jail carrying names on a backend that returns `false`. + +A 2.5 host refuses a 2.4 module, as for every minor: the module must advertise +the members the host may send. From 0970aecf5d020da1e65a65f1cfdcf4d3ef2a8f25 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 17:21:01 +0530 Subject: [PATCH 3/4] Fix the 2.5 wire snapshot, lints and formatting --- crates/tinybox-bus/src/jail_tests.rs | 6 ++- crates/tinybox-bus/src/lib_tests.rs | 2 +- crates/tinybox-jail/src/jail_tests.rs | 10 +++-- crates/tinybox-jail/src/lib.rs | 5 ++- crates/tinybox-jail/src/macos_tests.rs | 5 ++- .../src/tinybus_module/native_jail.rs | 37 ++++++++++++------- .../src/tinybus_module/native_jail_tests.rs | 10 ++++- .../src/tinybus_module/native_shell/mod.rs | 4 +- .../src/tinybus_module/native_shell/types.rs | 3 +- 9 files changed, 56 insertions(+), 26 deletions(-) diff --git a/crates/tinybox-bus/src/jail_tests.rs b/crates/tinybox-bus/src/jail_tests.rs index ebe81b35..54551483 100644 --- a/crates/tinybox-bus/src/jail_tests.rs +++ b/crates/tinybox-bus/src/jail_tests.rs @@ -106,7 +106,11 @@ fn native_jail_21_resource_subset_is_additive_and_keeps_disk_explicit() 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()); + 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"])); diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 080cf342..120d1ee7 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -168,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::(snapshot)?, diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index da7c8e92..b0e32236 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -328,10 +328,12 @@ fn a_backend_that_cannot_deny_by_name_refuses_a_name_denial() { 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) - .err() - .expect("refused"); + 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}"); + assert!( + error.to_string().contains("cannot deny paths by name"), + "{error}" + ); } diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index ed9c981c..bad11953 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -231,7 +231,10 @@ fn require_name_denials(backend: &dyn JailBackend, jail: &Jail) -> std::io::Resu } Err(std::io::Error::new( std::io::ErrorKind::Unsupported, - format!("jail backend `{}` cannot deny paths by name", backend.name()), + format!( + "jail backend `{}` cannot deny paths by name", + backend.name() + ), )) } diff --git a/crates/tinybox-jail/src/macos_tests.rs b/crates/tinybox-jail/src/macos_tests.rs index 0c7caa44..7b3f3a70 100644 --- a/crates/tinybox-jail/src/macos_tests.rs +++ b/crates/tinybox-jail/src/macos_tests.rs @@ -120,7 +120,10 @@ fn names_are_denied_by_a_regex_after_every_allow() { #[test] fn regex_escape_quotes_every_metacharacter() { assert_eq!(regex_escape(".npmrc"), "\\.npmrc"); - assert_eq!(regex_escape("a(b)[c]{d}^$|?*+"), "a\\(b\\)\\[c\\]\\{d\\}\\^\\$\\|\\?\\*\\+"); + assert_eq!( + regex_escape("a(b)[c]{d}^$|?*+"), + "a\\(b\\)\\[c\\]\\{d\\}\\^\\$\\|\\?\\*\\+" + ); assert_eq!(regex_escape("plain"), "plain"); } diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index b514fbf0..bfbbd597 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -27,19 +27,7 @@ pub(super) fn prepare( { return Err(invalid()); } - if !config.deny_read_names.is_empty() && !backend.denies_by_name() { - return Err(super::resources::failure( - tinybox_bus::UNSUPPORTED_OPERATION, - &format!( - "requested native jail enforcement is unavailable: backend `{}` cannot deny \ - paths by name", - backend.name() - ), - )); - } - if !config.deny_read_names.iter().all(|name| is_plain_name(name)) { - return Err(invalid()); - } + check_name_denials(config, backend)?; // Before anything is canonicalized: a link the jailed program planted // (`/vendor -> `) is only visible in the raw spellings. { @@ -131,6 +119,29 @@ pub(super) fn prepare( Ok(jail) } +/// Name denials need a backend that denies by name, and each name must be one +/// plain path component. +fn check_name_denials(config: &NativeJailConfig, backend: &dyn JailBackend) -> Result<()> { + if !config.deny_read_names.is_empty() && !backend.denies_by_name() { + return Err(super::resources::failure( + tinybox_bus::UNSUPPORTED_OPERATION, + &format!( + "requested native jail enforcement is unavailable: backend `{}` cannot deny \ + paths by name", + backend.name() + ), + )); + } + if !config + .deny_read_names + .iter() + .all(|name| is_plain_name(name)) + { + return Err(invalid()); + } + Ok(()) +} + fn configure_resources(jail: &mut Jail, config: &NativeJailConfig) -> Result<()> { jail.resources = config.resources.map(|limits| tinybox_core::Resources { cpu_millis: limits.cpu_millis, 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 ff494051..38a20174 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -1600,7 +1600,13 @@ fn a_name_denial_is_refused_by_a_backend_that_cannot_deny_by_name() assert_eq!(name, tinybox_bus::UNSUPPORTED_OPERATION); assert!(message.contains("cannot deny paths by name"), "{message}"); } - other => panic!("expected a refusal, got {:?}", other.map(|jail| jail.deny_read_names)), + other => { + return Err(format!( + "expected a refusal, got {:?}", + other.map(|jail| jail.deny_read_names) + ) + .into()); + } } Ok(()) } @@ -1639,7 +1645,7 @@ fn a_name_that_is_not_one_plain_component_is_an_invalid_path() let (config, command) = name_request(root.path(), &[bad]); match prepare(root.path(), &config, &command, &backend) { Err(error) => assert_eq!(error.wire_name(), tinybox_bus::INVALID_FILE_PATH, "{bad:?}"), - Ok(_) => panic!("{bad:?} was admitted"), + Ok(_) => return Err(format!("{bad:?} was admitted").into()), } } Ok(()) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index f18cbb1f..7f110f0d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -118,14 +118,14 @@ impl ShellContext { { return Err(invalid()); } - ShellHost::Jailed( + ShellHost::Jailed(Box::new( tinybox_host::JailLocalHost::with_backend( jail, backend, tinybox_bus::MAX_OUTPUT_BYTES, ) .map_err(|_| invalid())?, - ) + )) } else { ShellHost::Local(tinybox_host::LimitedLocalHost::new( tinybox_bus::MAX_OUTPUT_BYTES, diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs index e2c6c408..0b04e9f1 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs @@ -11,5 +11,6 @@ pub(in crate::tinybus_module) struct ShellContext { } pub(super) enum ShellHost { Local(tinybox_host::LimitedLocalHost), - Jailed(tinybox_host::JailLocalHost), + // Boxed: the jail it holds is far larger than a local host. + Jailed(Box), } From 4c95935ebe965ab71853262cd0e88cab0341db69 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 17:03:16 +0530 Subject: [PATCH 4/4] Make a resource budget's disk limit optional (contract 2.6) ResourceLimits.disk_bytes was a required u64 defaulting to 8 GiB, and an explicit native jail budget turned it into a hard Disk requirement that no backend enforces, so every legacy jail request with resources was refused. disk_bytes is now Option, absent by default and on the wire when unset. A native jail requires Disk only when the caller sets one; a box keeps the soft core default. Contract 2.6, since a module before it cannot decode a budget without the field. --- crates/tinybox-bus/src/event_tests.rs | 2 +- crates/tinybox-bus/src/jail_tests.rs | 2 +- crates/tinybox-bus/src/lib.rs | 16 +++++--- crates/tinybox-bus/src/lib_tests.rs | 37 ++++++++++++++++--- .../src/tinybus_module/native_jail.rs | 27 ++++++++------ .../src/tinybus_module/native_jail_tests.rs | 25 ++++++++++++- .../src/tinybus_module/resources.rs | 7 +++- .../src/tinybus_module/resources_tests.rs | 21 ++++++++++- docs/specs/contract-2-6.md | 23 ++++++++++++ 9 files changed, 132 insertions(+), 28 deletions(-) create mode 100644 docs/specs/contract-2-6.md diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs index 664941ab..c9fb10fe 100644 --- a/crates/tinybox-bus/src/event_tests.rs +++ b/crates/tinybox-bus/src/event_tests.rs @@ -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, 5)); + assert_eq!(CONTRACT_VERSION, (2, 6)); Ok(()) } diff --git a/crates/tinybox-bus/src/jail_tests.rs b/crates/tinybox-bus/src/jail_tests.rs index 54551483..fb55909d 100644 --- a/crates/tinybox-bus/src/jail_tests.rs +++ b/crates/tinybox-bus/src/jail_tests.rs @@ -60,7 +60,7 @@ fn native_resource_limits_are_optional_typed_and_round_trip() -> Result<(), serd cpu_millis: 500, memory_bytes: 1_048_576, pids_max: 32, - disk_bytes: 10_485_760, + disk_bytes: Some(10_485_760), }) ); assert_eq!(serde_json::to_value(config)?, request); diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 392a662b..5ab063f0 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -91,8 +91,10 @@ pub use files::{ /// its owner across uses; the `agent37` backend honours it and every other /// 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); +/// most-restrictive `Default` for [`NativeJailConfig`]. 2.6 makes +/// [`ResourceLimits::disk_bytes`] optional and absent by default; a host must +/// bind a 2.6 module before it omits the field. +pub const CONTRACT_VERSION: (u32, u32) = (2, 6); /// Minimum contract version required for resource-aware native jail members. pub const NATIVE_JAIL_RESOURCES_VERSION: (u32, u32) = (2, 1); @@ -438,8 +440,12 @@ pub struct ResourceLimits { pub memory_bytes: u64, /// Maximum number of processes and threads. pub pids_max: u32, - /// Maximum writable filesystem size in bytes. - pub disk_bytes: u64, + /// Maximum writable filesystem size in bytes. Optional from contract 2.6, + /// and absent by default: no backend enforces a disk budget, so a value + /// is a request a native jail must refuse rather than a default it + /// pretends to apply. Absent on the wire when `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disk_bytes: Option, } impl Default for ResourceLimits { @@ -448,7 +454,7 @@ impl Default for ResourceLimits { cpu_millis: 2_000, memory_bytes: 2 * 1024 * 1024 * 1024, pids_max: 512, - disk_bytes: 8 * 1024 * 1024 * 1024, + disk_bytes: None, } } } diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 120d1ee7..28411427 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -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, 5)); + assert_eq!(CONTRACT_VERSION, (2, 6)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -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, 5)); + assert_eq!(CONTRACT_VERSION, (2, 6)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 8))); @@ -158,7 +158,8 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() assert!(!is_compatible((2, 2))); assert!(!is_compatible((2, 3))); assert!(!is_compatible((2, 4))); - assert!(is_compatible((2, 5))); + assert!(!is_compatible((2, 5))); + assert!(is_compatible((2, 6))); assert!(!supports_native_jail_resources((2, 0))); assert!(supports_native_jail_resources((2, 1))); assert!(supports_native_jail_resources((2, 2))); @@ -168,7 +169,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,5],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[2,6],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -177,6 +178,30 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() Ok(()) } +#[test] +fn disk_bytes_is_optional_and_absent_by_default() -> Result<(), serde_json::Error> { + // A 2.5 sender always set it; it still decodes, and re-encodes unchanged. + let older = serde_json::json!({ + "cpu_millis": 500, "memory_bytes": 1024, "pids_max": 8, "disk_bytes": 4096, + }); + let limits: ResourceLimits = serde_json::from_value(older.clone())?; + assert_eq!(limits.disk_bytes, Some(4096)); + assert_eq!(serde_json::to_value(limits)?, older); + + // Without one, nothing is claimed and nothing is sent. + let unset: ResourceLimits = serde_json::from_value(serde_json::json!({ + "cpu_millis": 500, "memory_bytes": 1024, "pids_max": 8, + }))?; + assert_eq!(unset.disk_bytes, None); + assert_eq!(ResourceLimits::default().disk_bytes, None); + assert!( + serde_json::to_value(ResourceLimits::default())? + .get("disk_bytes") + .is_none() + ); + Ok(()) +} + #[test] fn no_workspace_docker_operation_is_present_in_the_contract() { assert_eq!(METHODS.get(29), Some(&"RunDockerWithoutWorkspace")); @@ -187,7 +212,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (2, 5)); + assert_eq!(CONTRACT_VERSION, (2, 6)); } #[test] @@ -200,7 +225,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, 5)); + assert_eq!(CONTRACT_VERSION, (2, 6)); assert!(!METHODS.contains(&"AnalyzeCommand")); assert!(METHODS.contains(&"AnalyzeShell")); assert!(METHODS.contains(&"RunNativeShell")); diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index bfbbd597..076609e9 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -147,7 +147,11 @@ fn configure_resources(jail: &mut Jail, config: &NativeJailConfig) -> Result<()> cpu_millis: limits.cpu_millis, memory_bytes: limits.memory_bytes, pids_max: limits.pids_max, - disk_bytes: limits.disk_bytes, + // Required only when the caller set one; see + // `required_resource_constraints`. + disk_bytes: limits + .disk_bytes + .unwrap_or(tinybox_core::Resources::DEFAULT.disk_bytes), }); jail.hard_requirements .extend(required_resource_constraints(config.resources)); @@ -163,19 +167,20 @@ fn configure_resources(jail: &mut Jail, config: &NativeJailConfig) -> Result<()> fn required_resource_constraints( resources: Option, ) -> Vec { - if resources.is_some() { - // An explicitly supplied native jail budget is a hard request. The - // default values in a BoxSpec remain soft unless its own - // `hard_requirements` lists a constraint. - vec![ + // An explicitly supplied native jail budget is a hard request, disk + // included only when the caller set one. The default values in a BoxSpec + // remain soft unless its own `hard_requirements` lists a constraint. + resources.map_or_else(Vec::new, |limits| { + let mut constraints = vec![ tinybox_core::Constraint::Cpu, tinybox_core::Constraint::Memory, tinybox_core::Constraint::Pids, - tinybox_core::Constraint::Disk, - ] - } else { - Vec::new() - } + ]; + if limits.disk_bytes.is_some() { + constraints.push(tinybox_core::Constraint::Disk); + } + constraints + }) } /// Validate and canonicalize each existing absolute grant path. 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 38a20174..e5e3a0de 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -203,7 +203,7 @@ fn requested_native_resource_limits_are_refused_before_spawn() cpu_millis: 500, memory_bytes: 1024 * 1024, pids_max: 32, - disk_bytes: 1024 * 1024, + disk_bytes: Some(1024 * 1024), }); // Declared enforcement accepts best-effort filesystem constraints, but a // resource budget remains a hard requirement and must refuse before spawn. @@ -211,6 +211,27 @@ fn requested_native_resource_limits_are_refused_before_spawn() Ok(()) } +#[test] +fn a_native_jail_budget_requires_disk_only_when_one_is_set() { + use tinybox_core::Constraint::{Cpu, Disk, Memory, Pids}; + let mut limits = tinybox_bus::ResourceLimits { + cpu_millis: 500, + memory_bytes: 1024 * 1024, + pids_max: 32, + disk_bytes: None, + }; + assert_eq!( + required_resource_constraints(Some(limits)), + vec![Cpu, Memory, Pids] + ); + limits.disk_bytes = Some(1024 * 1024); + assert_eq!( + required_resource_constraints(Some(limits)), + vec![Cpu, Memory, Pids, Disk] + ); + assert_eq!(required_resource_constraints(None), vec![]); +} + #[cfg(not(target_os = "linux"))] #[test] fn contract_21_resource_requests_refuse_before_spawn_on_non_linux() @@ -285,7 +306,7 @@ fn legacy_resource_limits_still_fail_closed_on_linux() cpu_millis: 100, memory_bytes: 64 * 1024 * 1024, pids_max: 16, - disk_bytes: 1024, + disk_bytes: Some(1024), }); assert!( diff --git a/crates/tinybox-module/src/tinybus_module/resources.rs b/crates/tinybox-module/src/tinybus_module/resources.rs index c806bfc8..5606d8f8 100644 --- a/crates/tinybox-module/src/tinybus_module/resources.rs +++ b/crates/tinybox-module/src/tinybus_module/resources.rs @@ -1687,7 +1687,12 @@ fn make_spec(request: &CreateRequest, host: &str) -> Result { cpu_millis: request.resources.cpu_millis, memory_bytes: request.resources.memory_bytes, pids_max: request.resources.pids_max, - disk_bytes: request.resources.disk_bytes, + // A box's resources are soft unless listed as hard requirements, and + // nothing applies a disk budget; keep the core default when unset. + disk_bytes: request + .resources + .disk_bytes + .unwrap_or(tinybox_core::Resources::DEFAULT.disk_bytes), }; spec.ports .extend(request.ports.iter().map(|port| tinybox_core::PortMapping { diff --git a/crates/tinybox-module/src/tinybus_module/resources_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_tests.rs index 410ab21c..ec45990e 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_tests.rs @@ -63,7 +63,7 @@ fn sandbox_selection_and_spec_mapping_preserve_host_policy() -> Result<()> { cpu_millis: 1500, memory_bytes: 1_000_000, pids_max: 12, - disk_bytes: 2_000_000, + disk_bytes: Some(2_000_000), }, ports: vec![tinybox_bus::PortMapping { guest: 8080, @@ -86,6 +86,25 @@ fn sandbox_selection_and_spec_mapping_preserve_host_policy() -> Result<()> { Ok(()) } +#[test] +fn an_unset_disk_budget_keeps_the_soft_core_default() -> Result<()> { + let request = CreateRequest { + resource: ResourceId("disk-test".into()), + backend: "docker".into(), + workspace: Workspace::Directory("/work".into()), + ..Default::default() + }; + assert_eq!(request.resources.disk_bytes, None); + let spec = make_spec(&request, "local")?; + assert_eq!( + spec.resources.disk_bytes, + tinybox_core::Resources::DEFAULT.disk_bytes + ); + // Soft: nothing asks a backend to enforce it. + assert_eq!(spec.hard_requirements, vec![]); + Ok(()) +} + #[test] fn spec_mapping_carries_retention_into_the_box_spec() -> Result<()> { let request = CreateRequest { diff --git a/docs/specs/contract-2-6.md b/docs/specs/contract-2-6.md new file mode 100644 index 00000000..ac497186 --- /dev/null +++ b/docs/specs/contract-2-6.md @@ -0,0 +1,23 @@ +# TinyBox contract 2.6 + +Contract 2.6 makes one field optional, without changing any other 2.5 request +or reply shape: + +| Change | What a host may rely on | +| --- | --- | +| `ResourceLimits.disk_bytes` | `Option`, `None` by default, omitted on the wire when `None`. A value sent by an older host still decodes as `Some`. | + +No backend enforces a writable disk budget, so the 8 GiB default was a number +nothing applied: + +- On `Create`, a box's resources stay soft unless the caller lists them as + hard requirements, and an unset `disk_bytes` keeps the core default + (`Resources::DEFAULT`). +- On a native jail, `NativeJailConfig.resources` remains a hard request for + CPU, memory and pids, and asks for `Disk` only when `disk_bytes` is set. + Before 2.6 every such request carried a disk budget and was refused by every + backend; without one it now reaches backends able to hold the other three. + +A 2.6 host refuses a 2.5 module, as for every minor. That matters here: a +module before 2.6 fails to decode a `ResourceLimits` without `disk_bytes`, so a +host must have bound a 2.6 module before it sends one.