Skip to content
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, 6));
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
30 changes: 29 additions & 1 deletion crates/tinybox-bus/src/jail_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
18 changes: 13 additions & 5 deletions crates/tinybox-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,12 @@ 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`]. 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);
Expand Down Expand Up @@ -436,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<u64>,
}

impl Default for ResourceLimits {
Expand All @@ -446,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,
}
}
}
Expand Down
38 changes: 32 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, 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);
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, 6));
assert!(is_compatible(CONTRACT_VERSION));
assert!(!is_compatible((1, 21)));
assert!(!is_compatible((1, 8)));
Expand All @@ -157,7 +157,9 @@ 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!(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)));
Expand All @@ -167,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,4],"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::<ModuleCapabilities>(snapshot)?,
Expand All @@ -176,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"));
Expand All @@ -186,7 +212,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() {
.count(),
1
);
assert_eq!(CONTRACT_VERSION, (2, 4));
assert_eq!(CONTRACT_VERSION, (2, 6));
}

#[test]
Expand All @@ -199,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, 4));
assert_eq!(CONTRACT_VERSION, (2, 6));
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
Loading
Loading