Skip to content
Open
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
23 changes: 23 additions & 0 deletions crates/tinybox-bus/src/grants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ pub struct ResolveLocalGrantsRequest {
pub config: LocalGrantConfig,
/// Host's unconditional protected-path floor.
pub protected: ProtectedPaths,
/// Permit canonical non-UTF-8 Unix grant paths in `unix_paths`.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_unix_paths: bool,
}

/// Canonical existing filesystem grants beyond a workload's writable root.
Expand All @@ -58,4 +61,24 @@ pub struct LocalGrants {
pub read_only: Vec<String>,
/// Read, executable, and writable paths.
pub read_write: Vec<String>,
/// Canonical Unix paths whose OS bytes are not UTF-8.
#[serde(default, skip_serializing_if = "UnixPathGrants::is_empty")]
pub unix_paths: UnixPathGrants,
}

/// Non-UTF-8 Unix grant paths transported as their original OS bytes.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnixPathGrants {
/// Read and executable paths.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_only: Vec<Vec<u8>>,
/// Read, executable, and writable paths.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_write: Vec<Vec<u8>>,
}

impl UnixPathGrants {
pub(crate) fn is_empty(&self) -> bool {
self.read_only.is_empty() && self.read_write.is_empty()
}
}
3 changes: 3 additions & 0 deletions crates/tinybox-bus/src/jail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub struct NativeJailConfig {
pub read_only: Vec<String>,
/// Existing absolute paths additionally granted write access.
pub read_write: Vec<String>,
/// Approved non-UTF-8 Unix grant paths, represented by their OS bytes.
#[serde(default, skip_serializing_if = "crate::UnixPathGrants::is_empty")]
pub unix_paths: crate::UnixPathGrants,
/// Whether outgoing network access is permitted.
pub allow_net: bool,
/// Whether child processes are permitted.
Expand Down
40 changes: 40 additions & 0 deletions crates/tinybox-bus/src/jail_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Native jail vocabulary is serialized independently of implementation types.
use super::*;
use crate::{LocalGrants, UnixPathGrants};

#[test]
fn native_jail_request_keeps_explicit_enforcement_and_grants() -> Result<(), serde_json::Error> {
Expand All @@ -17,3 +18,42 @@ fn native_jail_request_keeps_explicit_enforcement_and_grants() -> Result<(), ser
);
Ok(())
}

#[test]
fn unix_path_fields_default_and_empty_values_keep_legacy_wire_shape()
-> Result<(), serde_json::Error> {
let value = serde_json::json!({
"read_only": ["/usr"], "read_write": [], "allow_net": true,
"allow_subprocess": false, "enforcement": "enforced",
"require_process_isolation": true
});
let config: NativeJailConfig = serde_json::from_value(value.clone())?;
assert!(config.unix_paths.is_empty());
assert_eq!(serde_json::to_value(config)?, value);
let grants = UnixPathGrants::default();
assert_eq!(serde_json::to_value(grants)?, serde_json::json!({}));
Ok(())
}

#[test]
fn unix_grant_bytes_round_trip_without_text_conversion() -> Result<(), serde_json::Error> {
let grants = UnixPathGrants {
read_only: vec![vec![b'/', b't', 0xff]],
read_write: vec![vec![b'/', b'w', 0xfe]],
};
let encoded = serde_json::to_value(&grants)?;
assert_eq!(serde_json::from_value::<UnixPathGrants>(encoded)?, grants);
Ok(())
}

#[test]
fn local_grant_result_defaults_to_the_legacy_utf8_wire_shape() -> Result<(), serde_json::Error> {
let value = serde_json::json!({
"read_only": ["/usr"],
"read_write": ["/workspace"]
});
let grants: LocalGrants = serde_json::from_value(value.clone())?;
assert!(grants.unix_paths.is_empty());
assert_eq!(serde_json::to_value(grants)?, value);
Ok(())
}
7 changes: 5 additions & 2 deletions crates/tinybox-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

mod grants;
pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest};
pub use grants::{
LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest, UnixPathGrants,
};

mod jail;
pub use jail::{JailEnforcement, NativeJailConfig};
Expand Down Expand Up @@ -66,7 +68,8 @@ pub use files::{
/// suppression for a native shell child; ignored elsewhere).
/// 1.19 adds `NetworkPolicy::Allowlist` and the observational
/// [`SandboxEvent`] vocabulary (`Started`, `Denied`, `LimitHit`, `Exited`).
pub const CONTRACT_VERSION: (u32, u32) = (1, 19);
/// 1.20 adds opted-in byte-preserving Unix grant transport.
pub const CONTRACT_VERSION: (u32, u32) = (1, 20);

/// Whether this host vocabulary can bind to a module's advertised version.
/// The major must match and the module's minor must include every host member.
Expand Down
13 changes: 9 additions & 4 deletions crates/tinybox-bus/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso
assert_eq!(METHODS[29], "CleanupDocker");
assert_eq!(METHODS[30], "RunDockerWithoutWorkspace");
assert_eq!(METHODS[34], "StartNativeShellExec");
assert_eq!(CONTRACT_VERSION, (1, 19));
assert_eq!(CONTRACT_VERSION, (1, 20));
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 @@ -129,8 +129,10 @@ 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, (1, 19));
assert_eq!(CONTRACT_VERSION, (1, 20));
assert!(is_compatible(CONTRACT_VERSION));
assert!(!is_compatible((1, 19)));
assert!(!is_compatible((1, 18)));
assert!(!is_compatible((1, 8)));
assert!(!is_compatible((1, 7)));
assert!(!is_compatible((1, 6)));
Expand All @@ -147,7 +149,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":[1,19],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]});
let snapshot = serde_json::json!({"contract_version":[1,20],"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 @@ -166,7 +168,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() {
.count(),
1
);
assert_eq!(CONTRACT_VERSION, (1, 19));
assert_eq!(CONTRACT_VERSION, (1, 20));
}

#[test]
Expand Down Expand Up @@ -265,11 +267,14 @@ fn local_grant_configuration_defaults_and_floor_round_trip() -> Result<(), serde
prefixes: vec!["/etc".into()],
credential_roots: vec!["/operator/.ssh".into()],
},
allow_unix_paths: false,
};
assert_eq!(
serde_json::from_value::<ResolveLocalGrantsRequest>(serde_json::to_value(&request)?)?,
request
);
let wire = serde_json::to_value(&request)?;
assert_eq!(wire.get("allow_unix_paths"), None);
Ok(())
}

Expand Down
11 changes: 10 additions & 1 deletion crates/tinybox-jail/src/grants/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

resolve_local_jail_grants returns canonical existing JailGrants through the
workspace Result type. ResolveLocalGrants exposes the same behavior through
the module, with UTF-8 paths and an input/final 64-grant limit.
the module, with UTF-8 string paths by default. Contract 1.19 callers can opt
into raw non-UTF8 Unix path groups; all paths share the input/final 64-grant limit.

mod.rs orchestrates discovery and read-only precedence; types.rs holds results.
floor.rs matches components, adjacent pairs, protected prefixes and credential
Expand Down Expand Up @@ -30,3 +31,11 @@ read-only. Mutable tools need separately approved disposable state.
Hosts supply the complete protected floor and keep authorization. This module
does not start commands or weaken unavailable confinement. See
[the contract specification](../../../../docs/specs/local-grants.md).

Custom existing absolute nonempty `RUSTUP_HOME` and `CARGO_HOME` directories are
also discovered. Rustup is granted read-only as a whole tree. Cargo receives only
`bin`, `config.toml`, `config`, `env`, `registry` and `git`; the Cargo root stays
excluded. Empty, relative, missing and non-directory custom roots are skipped.
Native OS paths retain their original bytes through discovery. The caller's
protected floor and canonical alias inspection apply to every custom root, and
writable extras cannot reach fixed toolchain state or Cargo roots.
95 changes: 83 additions & 12 deletions crates/tinybox-jail/src/grants/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,39 @@ pub fn resolve_local_jail_grants(
protected: &ProtectedPaths,
) -> tinybox_core::Result<JailGrants> {
let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
resolve_local_jail_grants_in(home, xdg_config_home.as_deref(), cfg, protected)
let rustup_home = std::env::var_os("RUSTUP_HOME");
let cargo_home = std::env::var_os("CARGO_HOME");
resolve_local_jail_grants_with_homes(
home,
xdg_config_home.as_deref(),
rustup_home.as_deref(),
cargo_home.as_deref(),
cfg,
protected,
)
}

/// [`resolve_local_jail_grants`] with `XDG_CONFIG_HOME` passed in rather than
/// read from the process environment, so callers (and tests) stay hermetic.
#[cfg(test)]
pub(crate) fn resolve_local_jail_grants_in(
home: Option<&Path>,
xdg_config_home: Option<&std::ffi::OsStr>,
cfg: &LocalGrantConfig,
protected: &ProtectedPaths,
) -> tinybox_core::Result<JailGrants> {
resolve_local_jail_grants_with_homes(home, xdg_config_home, None, None, cfg, protected)
}

/// Resolve grants with all environment-derived roots supplied explicitly.
/// This keeps discovery testable without changing the process environment.
fn resolve_local_jail_grants_with_homes(
home: Option<&Path>,
xdg_config_home: Option<&std::ffi::OsStr>,
rustup_home: Option<&std::ffi::OsStr>,
cargo_home: Option<&std::ffi::OsStr>,
cfg: &LocalGrantConfig,
protected: &ProtectedPaths,
) -> tinybox_core::Result<JailGrants> {
let home = home.map(|h| h.canonicalize().unwrap_or_else(|_| h.to_path_buf()));
let mut b = Builder::new(home.as_deref(), protected);
Expand All @@ -56,11 +79,17 @@ pub(crate) fn resolve_local_jail_grants_in(
if let Some(h) = home.as_deref() {
b.add_cargo_home(&h.join(".cargo"));
for dir in HOME_READ_ONLY_DIRS {
b.fixed_read_only(&h.join(dir), "toolchain");
b.toolchain_read_only(&h.join(dir), "toolchain");
}
}
for dir in SYSTEM_TOOLCHAIN_DIRS {
b.fixed_read_only(Path::new(dir), "toolchain");
b.toolchain_read_only(Path::new(dir), "toolchain");
}
if let Some(path) = existing_absolute_directory(rustup_home) {
b.toolchain_read_only(&path, "custom Rustup home");
}
if let Some(path) = existing_absolute_directory(cargo_home) {
b.add_cargo_home(&path);
}
if let Some(h) = home.as_deref() {
b.add_gitconfig_at(h, xdg_config_home)?;
Expand Down Expand Up @@ -89,12 +118,18 @@ pub(crate) fn resolve_local_jail_grants_in(
Ok(b.grants)
}

fn existing_absolute_directory(raw: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
let path = PathBuf::from(raw?);
(!path.as_os_str().is_empty() && path.is_absolute() && path.is_dir()).then_some(path)
}

struct Builder<'a> {
home: Option<&'a Path>,
protected: &'a ProtectedPaths,
grants: JailGrants,
seen: HashSet<PathBuf>,
fixed_read_only: Vec<PathBuf>,
cargo_roots: Vec<PathBuf>,
}

impl<'a> Builder<'a> {
Expand All @@ -105,6 +140,7 @@ impl<'a> Builder<'a> {
grants: JailGrants::default(),
seen: HashSet::new(),
fixed_read_only: Vec::new(),
cargo_roots: Vec::new(),
}
}

Expand Down Expand Up @@ -164,22 +200,46 @@ impl<'a> Builder<'a> {
}

fn read_only(&mut self, path: &Path, source: &str) {
self.add_read_only(path, source, false);
let Some(canonical) = self.admit(path, source) else {
return;
};
if self.exposes_cargo_root(&canonical) {
log::warn!(
"refused extra grant containing the Cargo home root: {}",
path.display()
);
return;
}
self.record_read_only(canonical);
}

fn fixed_read_only(&mut self, path: &Path, source: &str) {
self.add_read_only(path, source, true);
}

/// Reserve toolchain roots even when the floor refuses their grant, so a
/// later writable extra cannot reintroduce the same host state.
fn toolchain_read_only(&mut self, path: &Path, source: &str) {
self.fixed_read_only.push(path.to_path_buf());
if let Ok(canonical) = path.canonicalize() {
self.fixed_read_only.push(canonical);
}
self.add_read_only(path, source, false);
}

fn add_read_only(&mut self, path: &Path, source: &str, fixed: bool) {
if let Some(p) = self.admit(path, source) {
if fixed {
self.fixed_read_only.push(path.to_path_buf());
self.fixed_read_only.push(p.clone());
}
if self.seen.insert(p.clone()) {
self.grants.read_only.push(p);
}
self.record_read_only(p);
}
}

fn record_read_only(&mut self, path: PathBuf) {
if self.seen.insert(path.clone()) {
self.grants.read_only.push(path);
}
}

Expand All @@ -198,7 +258,7 @@ impl<'a> Builder<'a> {
.chain(CARGO_READ_ONLY_FILES.iter().copied())
.any(|entry| reaches(&home.join(".cargo").join(entry)))
});
if exposes_fixed || exposes_cargo {
if exposes_fixed || exposes_cargo || self.exposes_cargo_root(&p) {
log::warn!(
"refused writable grant reaching fixed host toolchain state: {}",
p.display()
Expand All @@ -223,14 +283,25 @@ impl<'a> Builder<'a> {
if !cargo.is_dir() {
return;
}
if let Ok(canonical) = cargo.canonicalize() {
self.cargo_roots.push(cargo.to_path_buf());
self.cargo_roots.push(canonical);
}
log::debug!("[sandbox:grants] granting ~/.cargo parts with host code read-only");
self.fixed_read_only(&cargo.join("bin"), "toolchain");
self.fixed_read_only(&cargo.join("registry"), "toolchain");
self.fixed_read_only(&cargo.join("git"), "toolchain");
self.toolchain_read_only(&cargo.join("bin"), "toolchain");
self.toolchain_read_only(&cargo.join("registry"), "toolchain");
self.toolchain_read_only(&cargo.join("git"), "toolchain");
for f in CARGO_READ_ONLY_FILES {
self.fixed_read_only(&cargo.join(f), "toolchain");
self.toolchain_read_only(&cargo.join(f), "toolchain");
}
}

fn exposes_cargo_root(&self, path: &Path) -> bool {
let normalized = normalized_floor_path(&path.to_string_lossy());
self.cargo_roots.iter().any(|root| {
root_spellings(root).any(|cargo_root| same_or_descendant(&cargo_root, &normalized))
})
}
}

#[cfg(test)]
Expand Down
Loading
Loading