From 5a57c13465ea1e395a2c6eea41eeae7f60c006cf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:00:34 +0300 Subject: [PATCH 01/38] feat(jail): expose host-configured grant discovery over the bus Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinybox-bus/src/grants.rs | 60 +++ crates/tinybox-bus/src/lib.rs | 8 +- crates/tinybox-bus/src/lib_tests.rs | 31 +- crates/tinybox-jail/Cargo.toml | 2 + crates/tinybox-jail/src/grants.rs | 278 ++++++++++++ crates/tinybox-jail/src/grants_tests.rs | 406 ++++++++++++++++++ crates/tinybox-jail/src/lib.rs | 1 + .../src/tinybus_module/README.md | 13 +- .../tinybox-module/src/tinybus_module/mod.rs | 51 +++ .../src/tinybus_module/mod_tests.rs | 83 ++++ 11 files changed, 927 insertions(+), 7 deletions(-) create mode 100644 crates/tinybox-bus/src/grants.rs create mode 100644 crates/tinybox-jail/src/grants.rs create mode 100644 crates/tinybox-jail/src/grants_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 7c973ddc..9d72df46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1116,6 +1116,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tinybox-bus", "tinybox-core", "windows-sys 0.61.2", ] diff --git a/crates/tinybox-bus/src/grants.rs b/crates/tinybox-bus/src/grants.rs new file mode 100644 index 00000000..2f7772f0 --- /dev/null +++ b/crates/tinybox-bus/src/grants.rs @@ -0,0 +1,60 @@ +//! Host-configured filesystem grant discovery and protected-path vocabulary. +use serde::{Deserialize, Serialize}; + +/// Configuration used to discover toolchain and explicit filesystem grants. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct LocalGrantConfig { + /// Include existing toolchain homes and git configuration. + pub toolchain_homes: bool, + /// Additional read-only paths, accepting a leading tilde. + pub extra_read_only: Vec, + /// Additional writable paths, accepting a leading tilde. + pub extra_read_write: Vec, + /// Explicitly permit read access to `/proc`. + pub allow_proc: bool, +} +impl Default for LocalGrantConfig { + fn default() -> Self { + Self { + toolchain_homes: true, + extra_read_only: Vec::new(), + extra_read_write: Vec::new(), + allow_proc: false, + } + } +} + +/// Host-owned protected-path floor; matching ignores case and separator style. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProtectedPaths { + /// Forbidden path components at any depth (including credential filenames). + pub components: Vec, + /// Forbidden consecutive path-component pairs. + pub component_pairs: Vec<[String; 2]>, + /// Forbidden absolute roots and their descendants. + pub prefixes: Vec, + /// Credential roots whose ancestors must also never be granted. + pub credential_roots: Vec, +} + +/// Request to resolve existing local grant paths, without launching a command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolveLocalGrantsRequest { + /// Operator home; absent for hosts with no operator-home access. + pub home: Option, + /// Toolchain and additional-grant configuration. + pub config: LocalGrantConfig, + /// Host's unconditional protected-path floor. + pub protected: ProtectedPaths, +} + +/// Canonical existing filesystem grants beyond a workload's writable root. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalGrants { + /// Read and executable paths. + pub read_only: Vec, + /// Read, executable, and writable paths. + pub read_write: Vec, +} diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 0ef3ed7e..0dadb5c2 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -15,6 +15,9 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +mod grants; +pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest}; + mod jail; pub use jail::{JailEnforcement, NativeJailConfig}; @@ -36,8 +39,8 @@ pub use files::{ /// 1.4 adds detailed shell facts for host execution policy. 1.5 adds bounded /// file transfer for mounted workspaces. 1.6 adds bounded live native output. /// 1.7 adds explicitly approved native directory-jail execution. 1.8 adds a -/// bounded, module-owned one-shot Docker operation. -pub const CONTRACT_VERSION: (u32, u32) = (1, 8); +/// bounded, module-owned one-shot Docker operation. 1.9 adds local grant discovery. +pub const CONTRACT_VERSION: (u32, u32) = (1, 9); /// 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. @@ -107,6 +110,7 @@ pub const METHODS: &[&str] = &[ "ReleaseOutput", "StartJailedExec", "RunDocker", + "ResolveLocalGrants", ]; /// Facts about the detected native directory-jail backend. diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 65c749d8..09ab2b7b 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -45,8 +45,9 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso label: Some("openhuman.sandbox=true".into()), timeout_ms: 120_000, }; - assert_eq!(METHODS.last(), Some(&"RunDocker")); - assert_eq!(CONTRACT_VERSION, (1, 8)); + assert_eq!(METHODS[27], "RunDocker"); + assert_eq!(METHODS.last(), Some(&"ResolveLocalGrants")); + assert_eq!(CONTRACT_VERSION, (1, 9)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -122,7 +123,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, (1, 8)); + assert_eq!(CONTRACT_VERSION, (1, 9)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 7))); assert!(!is_compatible((1, 6))); @@ -139,7 +140,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,8],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,9],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -229,3 +230,25 @@ fn workspace_file_transfers_are_chunked_typed_and_bounded() -> Result<(), serde_ ); Ok(()) } + +#[test] +fn local_grant_configuration_defaults_and_floor_round_trip() -> Result<(), serde_json::Error> { + let config: LocalGrantConfig = serde_json::from_str("{}")?; + assert!(config.toolchain_homes); + assert!(!config.allow_proc); + let request = ResolveLocalGrantsRequest { + home: Some("/operator".into()), + config, + protected: ProtectedPaths { + components: vec![".ssh".into()], + component_pairs: vec![[".cargo".into(), "credentials.toml".into()]], + prefixes: vec!["/etc".into()], + credential_roots: vec!["/operator/.ssh".into()], + }, + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&request)?)?, + request + ); + Ok(()) +} diff --git a/crates/tinybox-jail/Cargo.toml b/crates/tinybox-jail/Cargo.toml index b4f32ce2..5aa83263 100644 --- a/crates/tinybox-jail/Cargo.toml +++ b/crates/tinybox-jail/Cargo.toml @@ -23,6 +23,8 @@ landlock = ["dep:landlock"] log = "0.4" # Reuse the workspace clock so registry timestamps can be controlled in tests. tinybox-core.workspace = true +# Serialized host-owned grant configuration and protected-path floor. +tinybox-bus.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs new file mode 100644 index 00000000..79456697 --- /dev/null +++ b/crates/tinybox-jail/src/grants.rs @@ -0,0 +1,278 @@ +//! Discover canonical local toolchain grants while preserving a host-supplied protected-path floor. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tinybox_bus::{LocalGrantConfig, ProtectedPaths}; + +/// System toolchain roots granted read-only when present. +const SYSTEM_TOOLCHAIN_DIRS: &[&str] = &["/usr/local", "/opt"]; + +/// `$HOME`-relative toolchain homes granted read-only when present. +const HOME_READ_ONLY_DIRS: &[&str] = &[".rustup", ".nvm", ".npm"]; + +/// Files Cargo needs read access to inside `~/.cargo` when the directory also +/// holds registry credentials and so cannot be granted whole. +const CARGO_READ_ONLY_FILES: &[&str] = &["config.toml", "config", "env"]; + +/// Git config files read at startup, relative to `$HOME`. +const GITCONFIG_FILES: &[&str] = &[".gitconfig", ".config/git/config", ".config/git/ignore"]; + +/// Deepest `include` chain followed when collecting git config files. +const GITCONFIG_MAX_DEPTH: usize = 8; + +/// Paths the jail is granted beyond the workspace root, all canonical and +/// existing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JailGrants { + /// Canonical read and executable paths. + pub read_only: Vec, + /// Canonical writable paths. + pub read_write: Vec, +} + +/// Build the grant set for `home` (the user's home directory, if known). +#[must_use] +pub fn resolve_local_jail_grants( + home: Option<&Path>, + cfg: &LocalGrantConfig, + protected: &ProtectedPaths, +) -> JailGrants { + let home = home.map(|h| h.canonicalize().unwrap_or_else(|_| h.to_path_buf())); + let mut b = Builder::new(home.as_deref(), protected); + + if cfg.toolchain_homes { + if let Some(h) = home.as_deref() { + b.add_cargo_home(&h.join(".cargo")); + for dir in HOME_READ_ONLY_DIRS { + b.read_only(&h.join(dir), "toolchain"); + } + } + for dir in SYSTEM_TOOLCHAIN_DIRS { + b.read_only(Path::new(dir), "toolchain"); + } + if let Some(h) = home.as_deref() { + b.add_gitconfig(h); + } + } + for raw in &cfg.extra_read_only { + if let Some(p) = b.expand(raw) { + b.read_only(&p, "config"); + } + } + for raw in &cfg.extra_read_write { + if let Some(p) = b.expand(raw) { + b.read_write(&p, "config"); + } + } + // The one sanctioned route to `/proc`: it bypasses the floor deliberately. + if cfg.allow_proc && Path::new("/proc").exists() { + log::debug!("[sandbox:grants] granting read-only /proc (allow_proc = true)"); + b.grants.read_only.push(PathBuf::from("/proc")); + } + log::debug!( + "resolved {} read-only and {} writable local grants", + b.grants.read_only.len(), + b.grants.read_write.len() + ); + b.grants +} + +struct Builder<'a> { + home: Option<&'a Path>, + protected: &'a ProtectedPaths, + grants: JailGrants, + seen: HashSet, +} + +impl<'a> Builder<'a> { + fn new(home: Option<&'a Path>, protected: &'a ProtectedPaths) -> Self { + Self { + home, + protected, + grants: JailGrants::default(), + seen: HashSet::new(), + } + } + + /// Expand a leading `~` / `~/` against the home directory. + fn expand(&self, raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + if raw == "~" { + return self.home.map(Path::to_path_buf); + } + if let Some(rest) = raw.strip_prefix("~/") { + return self.home.map(|h| h.join(rest)); + } + Some(PathBuf::from(raw)) + } + + /// Canonicalize `path` and return it only if it exists and clears the + /// credential floor. Canonicalizing first means a symlink into `~/.ssh` + /// is judged by its target. + fn admit(&self, path: &Path, source: &str) -> Option { + let Ok(canonical) = path.canonicalize() else { + log::debug!("skipped missing {source} grant: {}", path.display()); + return None; + }; + if self.is_protected(&canonical) || self.exposes_credentials(&canonical) { + log::warn!( + "refused {source} grant reaching protected paths: {}", + canonical.display() + ); + return None; + } + Some(canonical) + } + + /// True when `path` is, contains, or sits inside a credential directory. + fn exposes_credentials(&self, path: &Path) -> bool { + self.protected.credential_roots.iter().any(|root| { + let cred = PathBuf::from(root); + let cred = cred.canonicalize().unwrap_or(cred); + path.starts_with(&cred) || cred.starts_with(path) + }) + } + + fn is_protected(&self, path: &Path) -> bool { + let normalized = path + .to_string_lossy() + .to_ascii_lowercase() + .replace('\\', "/"); + let components: Vec<_> = normalized + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + components.iter().any(|part| { + self.protected + .components + .iter() + .any(|blocked| part.eq_ignore_ascii_case(blocked)) + }) || components.windows(2).any(|pair| { + self.protected.component_pairs.iter().any(|blocked| { + pair[0].eq_ignore_ascii_case(&blocked[0]) + && pair[1].eq_ignore_ascii_case(&blocked[1]) + }) + }) || self.protected.prefixes.iter().any(|prefix| { + let prefix = prefix.to_ascii_lowercase().replace('\\', "/"); + normalized == prefix || normalized.starts_with(&format!("{prefix}/")) + }) + } + + fn read_only(&mut self, path: &Path, source: &str) { + if let Some(p) = self.admit(path, source) + && self.seen.insert(p.clone()) + { + self.grants.read_only.push(p); + } + } + + fn read_write(&mut self, path: &Path, source: &str) { + if let Some(p) = self.admit(path, source) { + // A path granted writable subsumes an earlier read-only grant. + self.grants.read_only.retain(|g| g != &p); + self.seen.insert(p.clone()); + if !self.grants.read_write.contains(&p) { + self.grants.read_write.push(p); + } + } + } + + /// Grant only the parts of `~/.cargo` that jailed Cargo needs. The root is + /// never writable: `bin` and Cargo configuration run later outside the + /// jail, so writable access there would persist an escape for host tools. + fn add_cargo_home(&mut self, cargo: &Path) { + if !cargo.is_dir() { + return; + } + log::debug!("[sandbox:grants] granting ~/.cargo parts with host code read-only"); + self.read_only(&cargo.join("bin"), "toolchain"); + self.read_write(&cargo.join("registry"), "toolchain"); + self.read_write(&cargo.join("git"), "toolchain"); + for f in CARGO_READ_ONLY_FILES { + self.read_only(&cargo.join(f), "toolchain"); + } + } + + /// Read-only grants for the user's git config files and everything they + /// `include`, each canonicalized (a config is often a symlink into a + /// dotfiles repo, and the jail needs the target). + fn add_gitconfig(&mut self, home: &Path) { + let mut roots: Vec = GITCONFIG_FILES.iter().map(|f| home.join(f)).collect(); + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + roots.push(Path::new(&xdg).join("git/config")); + } + let mut visited = HashSet::new(); + for root in roots { + self.add_gitconfig_file(&root, home, 0, &mut visited); + } + } + + fn add_gitconfig_file( + &mut self, + file: &Path, + home: &Path, + depth: usize, + visited: &mut HashSet, + ) { + let Ok(canonical) = file.canonicalize() else { + return; + }; + if !canonical.is_file() || !visited.insert(canonical.clone()) { + return; + } + self.read_only(&canonical, "gitconfig"); + if depth >= GITCONFIG_MAX_DEPTH { + return; + } + let Ok(text) = std::fs::read_to_string(&canonical) else { + return; + }; + // Relative includes resolve against the directory of the file *as + // named*, which for a symlinked config is the link's directory. + let base = file.parent().unwrap_or(home); + for target in include_paths(&text) { + let resolved = if let Some(rest) = target.strip_prefix("~/") { + home.join(rest) + } else if Path::new(&target).is_absolute() { + PathBuf::from(&target) + } else { + base.join(&target) + }; + self.add_gitconfig_file(&resolved, home, depth + 1, visited); + } + } +} + +/// `path = …` values inside `[include]` / `[includeIf …]` sections. +fn include_paths(config: &str) -> Vec { + let mut in_include = false; + let mut out = Vec::new(); + for line in config.lines() { + let line = line.trim(); + if let Some(section) = line.strip_prefix('[') { + let name = section.split([']', ' ', '"']).next().unwrap_or(""); + in_include = + name.eq_ignore_ascii_case("include") || name.eq_ignore_ascii_case("includeif"); + continue; + } + if !in_include { + continue; + } + if let Some((key, value)) = line.split_once('=') + && key.trim().eq_ignore_ascii_case("path") + { + let value = value.trim().trim_matches('"'); + if !value.is_empty() { + out.push(value.to_string()); + } + } + } + out +} + +#[cfg(test)] +#[path = "grants_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs new file mode 100644 index 00000000..06916f10 --- /dev/null +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -0,0 +1,406 @@ +//! Toolchain grant discovery and protected-path regression coverage. +use super::*; +use std::fs; +use std::path::{Path, PathBuf}; + +fn fake_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + for d in [ + ".ssh", ".gnupg", ".aws", ".cargo", ".rustup", ".nvm", ".npm", + ] { + fs::create_dir_all(home.path().join(d)).unwrap(); + } + home +} + +fn canon(p: &Path) -> PathBuf { + p.canonicalize().unwrap() +} + +fn has(grants: &[PathBuf], p: &Path) -> bool { + grants.iter().any(|g| g == p) +} + +fn all(g: &JailGrants) -> Vec { + g.read_only.iter().chain(&g.read_write).cloned().collect() +} + +/// A grant reaches a credential store if it IS one, is inside one, or contains +/// one (Landlock grants are recursive, so a parent grant exposes the child). +fn reaches_credentials(grants: &[PathBuf], home: &Path) -> Option { + for cred in [".ssh", ".gnupg", ".aws"] { + let cred = home.join(cred); + for g in grants { + if g.starts_with(&cred) || cred.starts_with(g) { + return Some(g.clone()); + } + } + } + None +} + +#[test] +fn credential_dirs_are_never_granted_by_default() { + let home = fake_home(); + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + assert_eq!(reaches_credentials(&all(&g), home.path()), None); +} + +#[test] +fn credential_dirs_are_never_granted_even_when_extras_ask() { + let home = fake_home(); + let cfg = LocalGrantConfig { + extra_read_only: vec![ + "~/.ssh".into(), + "~/.gnupg/".into(), + format!("{}", home.path().join(".aws").display()), + "~".into(), + format!("{}", home.path().display()), + "/".into(), + ], + extra_read_write: vec!["~/.ssh".into(), "~".into()], + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + assert_eq!(reaches_credentials(&all(&g), home.path()), None); + assert!(!g.read_only.iter().any(|p| p == Path::new("/"))); +} + +#[test] +#[cfg(unix)] +fn credential_floor_holds_for_a_symlink_into_a_credential_dir() { + let home = fake_home(); + std::os::unix::fs::symlink(home.path().join(".ssh"), home.path().join("innocent")).unwrap(); + let cfg = LocalGrantConfig { + extra_read_only: vec!["~/innocent".into()], + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + assert_eq!(reaches_credentials(&all(&g), home.path()), None); +} + +#[test] +fn toolchain_homes_are_granted_when_they_exist() { + let home = fake_home(); + let cargo = home.path().join(".cargo"); + for dir in ["bin", "registry", "git"] { + fs::create_dir_all(cargo.join(dir)).unwrap(); + } + for file in ["config.toml", "config", "env"] { + fs::write(cargo.join(file), "").unwrap(); + } + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let h = canon(home.path()); + let cargo = h.join(".cargo"); + assert!( + !has(&g.read_write, &cargo), + "Cargo home must never be writable" + ); + assert!(has(&g.read_only, &cargo.join("bin"))); + assert!(has(&g.read_write, &cargo.join("registry"))); + assert!(has(&g.read_write, &cargo.join("git"))); + for file in ["config.toml", "config", "env"] { + assert!( + has(&g.read_only, &cargo.join(file)), + "{file} should be read-only" + ); + } + for ro in [".rustup", ".nvm", ".npm"] { + assert!(has(&g.read_only, &h.join(ro)), "{ro} should be read-only"); + assert!( + !has(&g.read_write, &h.join(ro)), + "{ro} must not be writable" + ); + } + for sys in ["/usr/local", "/opt"] { + if Path::new(sys).exists() { + assert!(has(&g.read_only, &canon(Path::new(sys))), "{sys} missing"); + } + } +} + +#[test] +fn absent_toolchain_homes_are_not_granted() { + let home = tempfile::tempdir().unwrap(); + fs::create_dir_all(home.path().join(".nvm")).unwrap(); + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let h = canon(home.path()); + assert!(has(&g.read_only, &h.join(".nvm"))); + assert!(!all(&g).iter().any(|p| p.starts_with(h.join(".cargo")))); + assert!(!all(&g).iter().any(|p| p.starts_with(h.join(".rustup")))); +} + +#[test] +fn toolchain_homes_can_be_switched_off() { + let home = fake_home(); + fs::write(home.path().join(".gitconfig"), "[user]\n name = x\n").unwrap(); + let cfg = LocalGrantConfig { + toolchain_homes: false, + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + assert!(g.read_only.is_empty() && g.read_write.is_empty(), "{g:?}"); +} + +#[test] +fn cargo_home_with_registry_credentials_is_granted_piecewise() { + let home = fake_home(); + let cargo = home.path().join(".cargo"); + for d in ["bin", "registry", "git"] { + fs::create_dir_all(cargo.join(d)).unwrap(); + } + fs::write( + cargo.join("credentials.toml"), + "[registry]\ntoken = \"x\"\n", + ) + .unwrap(); + fs::write(cargo.join("config.toml"), "").unwrap(); + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let c = canon(&cargo); + assert!( + !has(&g.read_write, &c), + "whole ~/.cargo would expose credentials.toml" + ); + assert!(!all(&g).iter().any(|p| p.ends_with("credentials.toml"))); + assert!(has(&g.read_write, &c.join("registry"))); + assert!(has(&g.read_write, &c.join("git"))); + assert!(has(&g.read_only, &c.join("bin"))); + assert!(has(&g.read_only, &c.join("config.toml"))); +} + +#[test] +fn a_writable_grant_subsumes_the_same_path_read_only() { + let home = fake_home(); + let cfg = LocalGrantConfig { + extra_read_only: vec!["~/.npm".into()], + extra_read_write: vec!["~/.npm".into()], + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + let npm = canon(&home.path().join(".npm")); + assert!(has(&g.read_write, &npm)); + assert!(!has(&g.read_only, &npm)); +} + +#[test] +#[cfg(target_os = "linux")] +fn proc_is_off_by_default_and_only_the_toggle_grants_it() { + let home = fake_home(); + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + assert!(!all(&g).iter().any(|p| p.starts_with("/proc"))); + + // An `extra_*` entry cannot smuggle it in: only `allow_proc` does. + let cfg = LocalGrantConfig { + extra_read_only: vec!["/proc".into()], + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + assert!(!all(&g).iter().any(|p| p.starts_with("/proc"))); + + let cfg = LocalGrantConfig { + allow_proc: true, + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + assert!(has(&g.read_only, Path::new("/proc"))); + assert!(!has(&g.read_write, Path::new("/proc"))); +} + +#[test] +fn extras_expand_tilde_and_skip_missing_paths() { + let home = fake_home(); + fs::create_dir_all(home.path().join("dotfiles")).unwrap(); + fs::create_dir_all(home.path().join("data")).unwrap(); + let cfg = LocalGrantConfig { + extra_read_only: vec!["~/dotfiles".into(), "~/nope".into()], + extra_read_write: vec!["~/data/../data".into()], + ..LocalGrantConfig::default() + }; + let g = resolve(Some(home.path()), &cfg); + assert!(has(&g.read_only, &canon(&home.path().join("dotfiles")))); + assert!(has(&g.read_write, &canon(&home.path().join("data")))); + assert!(!all(&g).iter().any(|p| p.ends_with("nope"))); +} + +#[test] +#[cfg(unix)] +fn gitconfig_symlink_and_include_targets_are_canonicalized() { + let home = fake_home(); + let dots = home.path().join("dotfiles"); + fs::create_dir_all(&dots).unwrap(); + fs::write(dots.join("work.gitconfig"), "[user]\n email = a@b\n").unwrap(); + fs::write(dots.join("local.gitconfig"), "").unwrap(); + fs::write( + dots.join("gitconfig"), + "[user]\n name = x\n[include]\n path = dotfiles/work.gitconfig\n\ + [includeIf \"gitdir:~/w/\"]\n\tpath = ~/dotfiles/local.gitconfig\n\ + [include]\n path = ~/.ssh/leaky\n", + ) + .unwrap(); + std::os::unix::fs::symlink(dots.join("gitconfig"), home.path().join(".gitconfig")).unwrap(); + // The credential include must be dropped even though the file exists. + fs::write(home.path().join(".ssh/leaky"), "").unwrap(); + + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let d = canon(&dots); + assert!(has(&g.read_only, &d.join("gitconfig")), "{g:?}"); + assert!(has(&g.read_only, &d.join("work.gitconfig")), "{g:?}"); + assert!(has(&g.read_only, &d.join("local.gitconfig")), "{g:?}"); + assert_eq!(reaches_credentials(&all(&g), home.path()), None); +} + +#[test] +fn gitconfig_include_cycles_terminate() { + let home = fake_home(); + fs::write(home.path().join(".gitconfig"), "[include]\n path = ~/b\n").unwrap(); + fs::write(home.path().join("b"), "[include]\n path = ~/.gitconfig\n").unwrap(); + let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + assert!(has(&g.read_only, &canon(&home.path().join("b")))); +} + +#[test] +fn no_home_yields_only_system_toolchain_dirs() { + let g = resolve(None, &LocalGrantConfig::default()); + assert!(g.read_write.is_empty()); + assert!( + g.read_only + .iter() + .all(|p| p.starts_with("/usr/local") || p.starts_with("/opt")) + ); +} + +fn floor(home: Option<&Path>) -> ProtectedPaths { + ProtectedPaths { + components: [ + ".ssh", + ".gnupg", + ".aws", + ".azure", + ".kube", + "keychains", + ".netrc", + ".git-credentials", + ".pgpass", + ".npmrc", + ] + .into_iter() + .map(str::to_owned) + .collect(), + component_pairs: vec![ + [".cargo".into(), "credentials.toml".into()], + [".docker".into(), "config.json".into()], + ], + prefixes: [ + "/etc", + "/root", + "/boot", + "/proc", + "/sys", + "/system", + "c:/windows", + ] + .into_iter() + .map(str::to_owned) + .collect(), + credential_roots: home + .map(|home| { + [".ssh", ".gnupg", ".aws", ".azure", ".kube"] + .into_iter() + .map(|component| home.join(component).to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(), + } +} +fn resolve(home: Option<&Path>, config: &LocalGrantConfig) -> JailGrants { + resolve_local_jail_grants(home, config, &floor(home)) +} + +#[test] +fn custom_host_floor_excludes_direct_and_ancestor_grants() { + let home = fake_home(); + let private = home.path().join("host-secrets"); + fs::create_dir(&private).unwrap(); + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~".into(), "~/host-secrets".into()], + ..LocalGrantConfig::default() + }; + let protected = ProtectedPaths { + credential_roots: vec![private.to_string_lossy().into_owned()], + ..ProtectedPaths::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); + assert!(grants.read_only.is_empty()); +} + +#[test] +fn host_floor_matches_component_pairs_and_case_insensitive_components() { + let home = fake_home(); + fs::create_dir(home.path().join(".SSH")).unwrap(); + fs::write(home.path().join(".cargo/credentials.toml"), "secret").unwrap(); + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/.SSH".into(), "~/.cargo/credentials.toml".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve(Some(home.path()), &config); + assert!(grants.read_only.is_empty()); +} + +#[test] +fn extras_without_home_and_empty_values_are_skipped() { + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec![" ".into(), "~".into(), "~/unknown".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve(None, &config); + assert!(grants.read_only.is_empty()); +} + +#[test] +fn include_parser_ignores_non_include_sections_and_empty_paths() { + assert_eq!( + include_paths( + "[user]\npath = wrong\n[INCLUDE]\nignored\n other = wrong\n path = \"\"\n PATH = \"valid\"\n[includeIf \"gitdir:~/project\"]\n path = relative\n" + ), + ["valid", "relative"] + ); +} + +#[test] +fn host_floor_matches_windows_separators_and_absolute_prefix_boundaries() { + let protected = ProtectedPaths { + components: vec![".ssh".into()], + component_pairs: vec![["Microsoft".into(), "Protect".into()]], + prefixes: vec!["C:/Windows".into()], + credential_roots: Vec::new(), + }; + let builder = Builder::new(None, &protected); + assert!(builder.is_protected(Path::new(r"C:\Users\x\.SSH\id_rsa"))); + assert!(builder.is_protected(Path::new(r"C:\Users\x\Microsoft\Protect\keys"))); + assert!(builder.is_protected(Path::new(r"C:\WINDOWS\System32"))); + assert!(builder.is_protected(Path::new(r"C:\WINDOWS"))); + assert!(!builder.is_protected(Path::new(r"C:\WINDOWS-tools"))); +} + +#[test] +fn gitconfig_include_depth_is_bounded_and_non_files_are_skipped() { + let home = fake_home(); + fs::write( + home.path().join(".gitconfig"), + "[include]\n path = ~/next-0\n path = ~/.cargo\n", + ) + .unwrap(); + for index in 0..10 { + fs::write( + home.path().join(format!("next-{index}")), + format!("[include]\n path = ~/next-{}\n", index + 1), + ) + .unwrap(); + } + let grants = resolve(Some(home.path()), &LocalGrantConfig::default()); + assert!(has(&grants.read_only, &canon(&home.path().join("next-7")))); + assert!(!has(&grants.read_only, &canon(&home.path().join("next-8")))); +} diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index ca5ce20b..3eb5c0b8 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -49,6 +49,7 @@ #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] pub mod detect; +pub mod grants; pub mod jail; pub mod noop; pub mod registry; diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 48bed606..8141638a 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -42,7 +42,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | FinishFileWrite | FinishFileWriteRequest | FileWriteProgress | | AbortFileWrite | AbortFileWriteRequest | unit | -Capabilities advertises contract version 1.8; 1.0 denotes the original +Capabilities advertises contract version 1.9; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Version 1.1 added reserved resource/process ownership and terminal shutdown; 1.2 adds host @@ -259,3 +259,14 @@ alongside the SSH fixture in the command above. It executes the generated supervisor shell and verifies stream behavior and scoped cancellation without a Docker daemon; it does not establish container isolation. Provider live tests cover actual Docker when a local daemon is available. + +### Local grant discovery + +Contract 1.9 adds `ResolveLocalGrants`. The caller supplies its operator home, +`LocalGrantConfig`, and `ProtectedPaths` floor. TinyBox resolves existing paths, +canonicalizes symlinks, discovers toolchain homes and Git include chains, and +returns read-only and writable grants. Discovery does not start a process or +make an approval decision. Credential roots exclude both descendants and +ancestors; a grant on the operator home cannot expose `.ssh`. `/proc` is granted +only by the explicit `allow_proc` flag. Hosts must supply their complete floor +and apply authorization before using the returned grants in `StartJailedExec`. diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 9adfb045..6a57af4a 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -58,6 +58,57 @@ impl BoxService { ) -> TinyBusResult { std::future::ready(Ok(tinybox_core::shell::analysis::analyze_command(&command))).await } + /// Discover canonical grants using the caller's protected-path floor. + async fn resolve_local_grants( + &self, + request: tinybox_bus::ResolveLocalGrantsRequest, + ) -> TinyBusResult { + if request.config.extra_read_only.len() + request.config.extra_read_write.len() + > tinybox_bus::MAX_NATIVE_JAIL_GRANTS + { + return Err(resources::failure( + tinybox_bus::RESOURCE_LIMIT, + "too many local grant paths", + )); + } + let grants = tokio::task::spawn_blocking(move || { + tinybox_jail::grants::resolve_local_jail_grants( + request.home.as_deref().map(std::path::Path::new), + &request.config, + &request.protected, + ) + }) + .await + .map_err(|_| { + resources::failure(tinybox_bus::UNSUPPORTED_OPERATION, "grant discovery failed") + })?; + Ok(tinybox_bus::LocalGrants { + read_only: grants + .read_only + .into_iter() + .map(|path| { + path.into_os_string().into_string().map_err(|_| { + resources::failure( + tinybox_bus::INVALID_FILE_PATH, + "grant path is not UTF-8", + ) + }) + }) + .collect::>>()?, + read_write: grants + .read_write + .into_iter() + .map(|path| { + path.into_os_string().into_string().map_err(|_| { + resources::failure( + tinybox_bus::INVALID_FILE_PATH, + "grant path is not UTF-8", + ) + }) + }) + .collect::>>()?, + }) + } /// Report native jail enforcement facts without starting a workload. async fn jail_status(&self) -> TinyBusResult { let backend = tinybox_jail::default_backend(); diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 933ba6d2..3233af20 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -876,3 +876,86 @@ async fn bounded_live_output_roundtrips_through_the_bus_codec() -> tinybus::Resu proxy.call::<()>("Close", (resource,)).await?; Ok(()) } + +#[tokio::test] +async fn local_grant_discovery_refuses_home_ancestors_of_credentials() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + std::fs::create_dir(home.path().join(".ssh"))?; + std::fs::create_dir(home.path().join("data"))?; + let service = BoxService::default(); + let result = service + .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("home is not UTF-8")?.into()), + config: tinybox_bus::LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~".into(), "~/.ssh".into(), "~/data".into()], + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths { + components: vec![".ssh".into()], + credential_roots: vec![ + home.path() + .join(".ssh") + .to_str() + .ok_or("credential root is not UTF-8")? + .into(), + ], + ..tinybox_bus::ProtectedPaths::default() + }, + }) + .await?; + assert_eq!( + result.read_only, + vec![ + home.path() + .join("data") + .canonicalize()? + .to_str() + .ok_or("grant is not UTF-8")? + ] + ); + assert!(result.read_write.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn local_grant_discovery_rejects_excessive_requested_grants() { + let result = BoxService::default() + .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { + home: None, + config: tinybox_bus::LocalGrantConfig { + extra_read_only: vec!["/missing".into(); tinybox_bus::MAX_NATIVE_JAIL_GRANTS + 1], + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths::default(), + }) + .await; + assert!(result.is_err()); +} + +#[tokio::test] +#[cfg(unix)] +async fn local_grant_discovery_rejects_non_utf8_canonical_targets() +-> Result<(), Box> { + use std::os::unix::ffi::OsStringExt; + let home = tempfile::tempdir()?; + let target = home + .path() + .join(std::ffi::OsString::from_vec(vec![b'd', 0xff])); + std::fs::create_dir(&target)?; + std::os::unix::fs::symlink(&target, home.path().join("data"))?; + let result = BoxService::default() + .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("home is not UTF-8")?.into()), + config: tinybox_bus::LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/data".into()], + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths::default(), + }) + .await; + assert!(result.is_err()); + Ok(()) +} From b2145105bc15ce59d11ea8344199f888323aea65 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:04:42 +0300 Subject: [PATCH 02/38] test(jail): support case-insensitive filesystems and current Clippy Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 10 +++++----- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 06916f10..b70e3d15 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -261,7 +261,7 @@ fn gitconfig_include_cycles_terminate() { #[test] fn no_home_yields_only_system_toolchain_dirs() { let g = resolve(None, &LocalGrantConfig::default()); - assert!(g.read_write.is_empty()); + assert_eq!(g.read_write, Vec::::new()); assert!( g.read_only .iter() @@ -331,13 +331,13 @@ fn custom_host_floor_excludes_direct_and_ancestor_grants() { ..ProtectedPaths::default() }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); - assert!(grants.read_only.is_empty()); + assert_eq!(grants.read_only, Vec::::new()); } #[test] fn host_floor_matches_component_pairs_and_case_insensitive_components() { let home = fake_home(); - fs::create_dir(home.path().join(".SSH")).unwrap(); + fs::create_dir_all(home.path().join(".SSH")).unwrap(); fs::write(home.path().join(".cargo/credentials.toml"), "secret").unwrap(); let config = LocalGrantConfig { toolchain_homes: false, @@ -345,7 +345,7 @@ fn host_floor_matches_component_pairs_and_case_insensitive_components() { ..LocalGrantConfig::default() }; let grants = resolve(Some(home.path()), &config); - assert!(grants.read_only.is_empty()); + assert_eq!(grants.read_only, Vec::::new()); } #[test] @@ -356,7 +356,7 @@ fn extras_without_home_and_empty_values_are_skipped() { ..LocalGrantConfig::default() }; let grants = resolve(None, &config); - assert!(grants.read_only.is_empty()); + assert_eq!(grants.read_only, Vec::::new()); } #[test] diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 3233af20..dd500aca 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -915,7 +915,7 @@ async fn local_grant_discovery_refuses_home_ancestors_of_credentials() .ok_or("grant is not UTF-8")? ] ); - assert!(result.read_write.is_empty()); + assert_eq!(result.read_write, Vec::::new()); Ok(()) } From 6eb91d5ac8caebaada0412f029f0bfbb3336a483 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:21:57 +0300 Subject: [PATCH 03/38] fix(jail): enforce protected roots and complete grant contract admission Co-authored-by: Medulla --- crates/tinybox-bus/src/grants.rs | 3 +- crates/tinybox-jail/src/grants.rs | 43 +++- crates/tinybox-jail/src/grants_tests.rs | 222 ++++++++++++------ .../tinybox-module/src/tinybus_module/mod.rs | 8 +- .../src/tinybus_module/mod_tests.rs | 53 +++++ .../tests/grants_manifest_e2e.rs | 118 ++++++++++ 6 files changed, 362 insertions(+), 85 deletions(-) create mode 100644 crates/tinybox-module/tests/grants_manifest_e2e.rs diff --git a/crates/tinybox-bus/src/grants.rs b/crates/tinybox-bus/src/grants.rs index 2f7772f0..13258b10 100644 --- a/crates/tinybox-bus/src/grants.rs +++ b/crates/tinybox-bus/src/grants.rs @@ -33,7 +33,8 @@ pub struct ProtectedPaths { pub components: Vec, /// Forbidden consecutive path-component pairs. pub component_pairs: Vec<[String; 2]>, - /// Forbidden absolute roots and their descendants. + /// Forbidden absolute roots and their descendants. Trailing separators and + /// Windows extended-path namespaces are normalized before matching. pub prefixes: Vec, /// Credential roots whose ancestors must also never be granted. pub credential_roots: Vec, diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 79456697..01d93971 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -129,18 +129,17 @@ impl<'a> Builder<'a> { /// True when `path` is, contains, or sits inside a credential directory. fn exposes_credentials(&self, path: &Path) -> bool { + let path = normalized_floor_path(&path.to_string_lossy()); self.protected.credential_roots.iter().any(|root| { let cred = PathBuf::from(root); let cred = cred.canonicalize().unwrap_or(cred); - path.starts_with(&cred) || cred.starts_with(path) + let cred = normalized_floor_path(&cred.to_string_lossy()); + same_or_descendant(&path, &cred) || same_or_descendant(&cred, &path) }) } fn is_protected(&self, path: &Path) -> bool { - let normalized = path - .to_string_lossy() - .to_ascii_lowercase() - .replace('\\', "/"); + let normalized = normalized_floor_path(&path.to_string_lossy()); let components: Vec<_> = normalized .split('/') .filter(|part| !part.is_empty()) @@ -155,10 +154,11 @@ impl<'a> Builder<'a> { pair[0].eq_ignore_ascii_case(&blocked[0]) && pair[1].eq_ignore_ascii_case(&blocked[1]) }) - }) || self.protected.prefixes.iter().any(|prefix| { - let prefix = prefix.to_ascii_lowercase().replace('\\', "/"); - normalized == prefix || normalized.starts_with(&format!("{prefix}/")) - }) + }) || self + .protected + .prefixes + .iter() + .any(|prefix| same_or_descendant(&normalized, &normalized_floor_path(prefix))) } fn read_only(&mut self, path: &Path, source: &str) { @@ -246,6 +246,31 @@ impl<'a> Builder<'a> { } } +// Windows canonical paths may carry an extended drive or UNC namespace; +// compare the underlying filesystem path, regardless of spelling or case. +fn normalized_floor_path(path: &str) -> String { + let normalized = path.to_ascii_lowercase().replace('\\', "/"); + if let Some(rest) = normalized.strip_prefix("//?/unc/") { + format!("//{rest}") + } else { + normalized + .strip_prefix("//?/") + .unwrap_or(&normalized) + .to_owned() + } +} + +fn same_or_descendant(candidate: &str, parent: &str) -> bool { + let root = parent.trim_end_matches('/'); + if root.is_empty() { + return parent.starts_with('/') && candidate.starts_with('/'); + } + candidate == root + || candidate + .strip_prefix(root) + .is_some_and(|rest| rest.starts_with('/')) +} + /// `path = …` values inside `[include]` / `[includeIf …]` sections. fn include_paths(config: &str) -> Vec { let mut in_include = false; diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index b70e3d15..31f65d6c 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -3,18 +3,18 @@ use super::*; use std::fs; use std::path::{Path, PathBuf}; -fn fake_home() -> tempfile::TempDir { - let home = tempfile::tempdir().unwrap(); +fn fake_home() -> std::io::Result { + let home = tempfile::tempdir()?; for d in [ ".ssh", ".gnupg", ".aws", ".cargo", ".rustup", ".nvm", ".npm", ] { - fs::create_dir_all(home.path().join(d)).unwrap(); + fs::create_dir_all(home.path().join(d))?; } - home + Ok(home) } -fn canon(p: &Path) -> PathBuf { - p.canonicalize().unwrap() +fn canon(p: &Path) -> std::io::Result { + p.canonicalize() } fn has(grants: &[PathBuf], p: &Path) -> bool { @@ -40,15 +40,17 @@ fn reaches_credentials(grants: &[PathBuf], home: &Path) -> Option { } #[test] -fn credential_dirs_are_never_granted_by_default() { - let home = fake_home(); +fn credential_dirs_are_never_granted_by_default() -> Result<(), Box> { + let home = fake_home()?; let g = resolve(Some(home.path()), &LocalGrantConfig::default()); assert_eq!(reaches_credentials(&all(&g), home.path()), None); + Ok(()) } #[test] -fn credential_dirs_are_never_granted_even_when_extras_ask() { - let home = fake_home(); +fn credential_dirs_are_never_granted_even_when_extras_ask() -> Result<(), Box> +{ + let home = fake_home()?; let cfg = LocalGrantConfig { extra_read_only: vec![ "~/.ssh".into(), @@ -64,33 +66,36 @@ fn credential_dirs_are_never_granted_even_when_extras_ask() { let g = resolve(Some(home.path()), &cfg); assert_eq!(reaches_credentials(&all(&g), home.path()), None); assert!(!g.read_only.iter().any(|p| p == Path::new("/"))); + Ok(()) } #[test] #[cfg(unix)] -fn credential_floor_holds_for_a_symlink_into_a_credential_dir() { - let home = fake_home(); - std::os::unix::fs::symlink(home.path().join(".ssh"), home.path().join("innocent")).unwrap(); +fn credential_floor_holds_for_a_symlink_into_a_credential_dir() +-> Result<(), Box> { + let home = fake_home()?; + std::os::unix::fs::symlink(home.path().join(".ssh"), home.path().join("innocent"))?; let cfg = LocalGrantConfig { extra_read_only: vec!["~/innocent".into()], ..LocalGrantConfig::default() }; let g = resolve(Some(home.path()), &cfg); assert_eq!(reaches_credentials(&all(&g), home.path()), None); + Ok(()) } #[test] -fn toolchain_homes_are_granted_when_they_exist() { - let home = fake_home(); +fn toolchain_homes_are_granted_when_they_exist() -> Result<(), Box> { + let home = fake_home()?; let cargo = home.path().join(".cargo"); for dir in ["bin", "registry", "git"] { - fs::create_dir_all(cargo.join(dir)).unwrap(); + fs::create_dir_all(cargo.join(dir))?; } for file in ["config.toml", "config", "env"] { - fs::write(cargo.join(file), "").unwrap(); + fs::write(cargo.join(file), "")?; } let g = resolve(Some(home.path()), &LocalGrantConfig::default()); - let h = canon(home.path()); + let h = canon(home.path())?; let cargo = h.join(".cargo"); assert!( !has(&g.read_write, &cargo), @@ -114,49 +119,52 @@ fn toolchain_homes_are_granted_when_they_exist() { } for sys in ["/usr/local", "/opt"] { if Path::new(sys).exists() { - assert!(has(&g.read_only, &canon(Path::new(sys))), "{sys} missing"); + assert!(has(&g.read_only, &canon(Path::new(sys))?), "{sys} missing"); } } + Ok(()) } #[test] -fn absent_toolchain_homes_are_not_granted() { - let home = tempfile::tempdir().unwrap(); - fs::create_dir_all(home.path().join(".nvm")).unwrap(); +fn absent_toolchain_homes_are_not_granted() -> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join(".nvm"))?; let g = resolve(Some(home.path()), &LocalGrantConfig::default()); - let h = canon(home.path()); + let h = canon(home.path())?; assert!(has(&g.read_only, &h.join(".nvm"))); assert!(!all(&g).iter().any(|p| p.starts_with(h.join(".cargo")))); assert!(!all(&g).iter().any(|p| p.starts_with(h.join(".rustup")))); + Ok(()) } #[test] -fn toolchain_homes_can_be_switched_off() { - let home = fake_home(); - fs::write(home.path().join(".gitconfig"), "[user]\n name = x\n").unwrap(); +fn toolchain_homes_can_be_switched_off() -> Result<(), Box> { + let home = fake_home()?; + fs::write(home.path().join(".gitconfig"), "[user]\n name = x\n")?; let cfg = LocalGrantConfig { toolchain_homes: false, ..LocalGrantConfig::default() }; let g = resolve(Some(home.path()), &cfg); assert!(g.read_only.is_empty() && g.read_write.is_empty(), "{g:?}"); + Ok(()) } #[test] -fn cargo_home_with_registry_credentials_is_granted_piecewise() { - let home = fake_home(); +fn cargo_home_with_registry_credentials_is_granted_piecewise() +-> Result<(), Box> { + let home = fake_home()?; let cargo = home.path().join(".cargo"); for d in ["bin", "registry", "git"] { - fs::create_dir_all(cargo.join(d)).unwrap(); + fs::create_dir_all(cargo.join(d))?; } fs::write( cargo.join("credentials.toml"), "[registry]\ntoken = \"x\"\n", - ) - .unwrap(); - fs::write(cargo.join("config.toml"), "").unwrap(); + )?; + fs::write(cargo.join("config.toml"), "")?; let g = resolve(Some(home.path()), &LocalGrantConfig::default()); - let c = canon(&cargo); + let c = canon(&cargo)?; assert!( !has(&g.read_write, &c), "whole ~/.cargo would expose credentials.toml" @@ -166,26 +174,29 @@ fn cargo_home_with_registry_credentials_is_granted_piecewise() { assert!(has(&g.read_write, &c.join("git"))); assert!(has(&g.read_only, &c.join("bin"))); assert!(has(&g.read_only, &c.join("config.toml"))); + Ok(()) } #[test] -fn a_writable_grant_subsumes_the_same_path_read_only() { - let home = fake_home(); +fn a_writable_grant_subsumes_the_same_path_read_only() -> Result<(), Box> { + let home = fake_home()?; let cfg = LocalGrantConfig { extra_read_only: vec!["~/.npm".into()], extra_read_write: vec!["~/.npm".into()], ..LocalGrantConfig::default() }; let g = resolve(Some(home.path()), &cfg); - let npm = canon(&home.path().join(".npm")); + let npm = canon(&home.path().join(".npm"))?; assert!(has(&g.read_write, &npm)); assert!(!has(&g.read_only, &npm)); + Ok(()) } #[test] #[cfg(target_os = "linux")] -fn proc_is_off_by_default_and_only_the_toggle_grants_it() { - let home = fake_home(); +fn proc_is_off_by_default_and_only_the_toggle_grants_it() -> Result<(), Box> +{ + let home = fake_home()?; let g = resolve(Some(home.path()), &LocalGrantConfig::default()); assert!(!all(&g).iter().any(|p| p.starts_with("/proc"))); @@ -204,58 +215,62 @@ fn proc_is_off_by_default_and_only_the_toggle_grants_it() { let g = resolve(Some(home.path()), &cfg); assert!(has(&g.read_only, Path::new("/proc"))); assert!(!has(&g.read_write, Path::new("/proc"))); + Ok(()) } #[test] -fn extras_expand_tilde_and_skip_missing_paths() { - let home = fake_home(); - fs::create_dir_all(home.path().join("dotfiles")).unwrap(); - fs::create_dir_all(home.path().join("data")).unwrap(); +fn extras_expand_tilde_and_skip_missing_paths() -> Result<(), Box> { + let home = fake_home()?; + fs::create_dir_all(home.path().join("dotfiles"))?; + fs::create_dir_all(home.path().join("data"))?; let cfg = LocalGrantConfig { extra_read_only: vec!["~/dotfiles".into(), "~/nope".into()], extra_read_write: vec!["~/data/../data".into()], ..LocalGrantConfig::default() }; let g = resolve(Some(home.path()), &cfg); - assert!(has(&g.read_only, &canon(&home.path().join("dotfiles")))); - assert!(has(&g.read_write, &canon(&home.path().join("data")))); + assert!(has(&g.read_only, &canon(&home.path().join("dotfiles"))?)); + assert!(has(&g.read_write, &canon(&home.path().join("data"))?)); assert!(!all(&g).iter().any(|p| p.ends_with("nope"))); + Ok(()) } #[test] #[cfg(unix)] -fn gitconfig_symlink_and_include_targets_are_canonicalized() { - let home = fake_home(); +fn gitconfig_symlink_and_include_targets_are_canonicalized() +-> Result<(), Box> { + let home = fake_home()?; let dots = home.path().join("dotfiles"); - fs::create_dir_all(&dots).unwrap(); - fs::write(dots.join("work.gitconfig"), "[user]\n email = a@b\n").unwrap(); - fs::write(dots.join("local.gitconfig"), "").unwrap(); + fs::create_dir_all(&dots)?; + fs::write(dots.join("work.gitconfig"), "[user]\n email = a@b\n")?; + fs::write(dots.join("local.gitconfig"), "")?; fs::write( dots.join("gitconfig"), "[user]\n name = x\n[include]\n path = dotfiles/work.gitconfig\n\ [includeIf \"gitdir:~/w/\"]\n\tpath = ~/dotfiles/local.gitconfig\n\ [include]\n path = ~/.ssh/leaky\n", - ) - .unwrap(); - std::os::unix::fs::symlink(dots.join("gitconfig"), home.path().join(".gitconfig")).unwrap(); + )?; + std::os::unix::fs::symlink(dots.join("gitconfig"), home.path().join(".gitconfig"))?; // The credential include must be dropped even though the file exists. - fs::write(home.path().join(".ssh/leaky"), "").unwrap(); + fs::write(home.path().join(".ssh/leaky"), "")?; let g = resolve(Some(home.path()), &LocalGrantConfig::default()); - let d = canon(&dots); + let d = canon(&dots)?; assert!(has(&g.read_only, &d.join("gitconfig")), "{g:?}"); assert!(has(&g.read_only, &d.join("work.gitconfig")), "{g:?}"); assert!(has(&g.read_only, &d.join("local.gitconfig")), "{g:?}"); assert_eq!(reaches_credentials(&all(&g), home.path()), None); + Ok(()) } #[test] -fn gitconfig_include_cycles_terminate() { - let home = fake_home(); - fs::write(home.path().join(".gitconfig"), "[include]\n path = ~/b\n").unwrap(); - fs::write(home.path().join("b"), "[include]\n path = ~/.gitconfig\n").unwrap(); +fn gitconfig_include_cycles_terminate() -> Result<(), Box> { + let home = fake_home()?; + fs::write(home.path().join(".gitconfig"), "[include]\n path = ~/b\n")?; + fs::write(home.path().join("b"), "[include]\n path = ~/.gitconfig\n")?; let g = resolve(Some(home.path()), &LocalGrantConfig::default()); - assert!(has(&g.read_only, &canon(&home.path().join("b")))); + assert!(has(&g.read_only, &canon(&home.path().join("b"))?)); + Ok(()) } #[test] @@ -317,10 +332,11 @@ fn resolve(home: Option<&Path>, config: &LocalGrantConfig) -> JailGrants { } #[test] -fn custom_host_floor_excludes_direct_and_ancestor_grants() { - let home = fake_home(); +fn custom_host_floor_excludes_direct_and_ancestor_grants() -> Result<(), Box> +{ + let home = fake_home()?; let private = home.path().join("host-secrets"); - fs::create_dir(&private).unwrap(); + fs::create_dir(&private)?; let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["~".into(), "~/host-secrets".into()], @@ -332,13 +348,15 @@ fn custom_host_floor_excludes_direct_and_ancestor_grants() { }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); assert_eq!(grants.read_only, Vec::::new()); + Ok(()) } #[test] -fn host_floor_matches_component_pairs_and_case_insensitive_components() { - let home = fake_home(); - fs::create_dir_all(home.path().join(".SSH")).unwrap(); - fs::write(home.path().join(".cargo/credentials.toml"), "secret").unwrap(); +fn host_floor_matches_component_pairs_and_case_insensitive_components() +-> Result<(), Box> { + let home = fake_home()?; + fs::create_dir_all(home.path().join(".SSH"))?; + fs::write(home.path().join(".cargo/credentials.toml"), "secret")?; let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["~/.SSH".into(), "~/.cargo/credentials.toml".into()], @@ -346,6 +364,7 @@ fn host_floor_matches_component_pairs_and_case_insensitive_components() { }; let grants = resolve(Some(home.path()), &config); assert_eq!(grants.read_only, Vec::::new()); + Ok(()) } #[test] @@ -386,21 +405,76 @@ fn host_floor_matches_windows_separators_and_absolute_prefix_boundaries() { } #[test] -fn gitconfig_include_depth_is_bounded_and_non_files_are_skipped() { - let home = fake_home(); +fn gitconfig_include_depth_is_bounded_and_non_files_are_skipped() +-> Result<(), Box> { + let home = fake_home()?; fs::write( home.path().join(".gitconfig"), "[include]\n path = ~/next-0\n path = ~/.cargo\n", - ) - .unwrap(); + )?; for index in 0..10 { fs::write( home.path().join(format!("next-{index}")), format!("[include]\n path = ~/next-{}\n", index + 1), - ) - .unwrap(); + )?; } let grants = resolve(Some(home.path()), &LocalGrantConfig::default()); - assert!(has(&grants.read_only, &canon(&home.path().join("next-7")))); - assert!(!has(&grants.read_only, &canon(&home.path().join("next-8")))); + assert!(has(&grants.read_only, &canon(&home.path().join("next-7"))?)); + assert!(!has( + &grants.read_only, + &canon(&home.path().join("next-8"))? + )); + Ok(()) +} + +#[test] +fn protected_absolute_root_blocks_every_descendant() { + let protected = ProtectedPaths { + prefixes: vec!["/".into()], + ..ProtectedPaths::default() + }; + let builder = Builder::new(None, &protected); + assert!(builder.is_protected(Path::new("/opt"))); + assert!(builder.is_protected(Path::new("/usr/local/toolchain"))); + assert!(!builder.is_protected(Path::new("relative/toolchain"))); +} + +#[test] +fn protected_prefix_trailing_separators_keep_component_boundaries() { + let protected = ProtectedPaths { + prefixes: vec!["/opt/secrets/".into(), r"C:\Windows\".into()], + ..ProtectedPaths::default() + }; + let builder = Builder::new(None, &protected); + assert!(builder.is_protected(Path::new("/opt/secrets/key"))); + assert!(builder.is_protected(Path::new("/opt/secrets"))); + assert!(!builder.is_protected(Path::new("/opt/secrets-tools/key"))); + assert!(builder.is_protected(Path::new(r"C:\WINDOWS\System32"))); + assert!(builder.is_protected(Path::new(r"C:\WINDOWS"))); + assert!(!builder.is_protected(Path::new(r"C:\WINDOWS-tools"))); +} + +#[test] +fn protected_windows_prefixes_match_canonical_extended_path_names() { + let protected = ProtectedPaths { + prefixes: vec!["C:/Windows/".into(), "//server/secrets/".into()], + ..ProtectedPaths::default() + }; + let builder = Builder::new(None, &protected); + assert!(builder.is_protected(Path::new(r"\\?\C:\WINDOWS\System32"))); + assert!(!builder.is_protected(Path::new(r"\\?\C:\WINDOWS-tools"))); + assert!(builder.is_protected(Path::new(r"\\?\UNC\SERVER\secrets\key"))); + assert!(!builder.is_protected(Path::new(r"\\?\UNC\SERVER\secrets-tools"))); +} + +#[test] +fn credential_root_ancestors_match_case_and_separator_styles() { + let protected = ProtectedPaths { + credential_roots: vec!["c:/users/floor-fixture/.ssh".into()], + ..ProtectedPaths::default() + }; + let builder = Builder::new(None, &protected); + assert!(builder.exposes_credentials(Path::new(r"\\?\C:\Users\FLOOR-FIXTURE"))); + assert!(builder.exposes_credentials(Path::new(r"C:\USERS\floor-fixture\.SSH\id_rsa"))); + assert!(!builder.exposes_credentials(Path::new(r"C:\Users\floor-fixture-other"))); } diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 6a57af4a..05fe1737 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -82,6 +82,12 @@ impl BoxService { .map_err(|_| { resources::failure(tinybox_bus::UNSUPPORTED_OPERATION, "grant discovery failed") })?; + if grants.read_only.len() + grants.read_write.len() > tinybox_bus::MAX_NATIVE_JAIL_GRANTS { + return Err(resources::failure( + tinybox_bus::RESOURCE_LIMIT, + "too many discovered local grant paths", + )); + } Ok(tinybox_bus::LocalGrants { read_only: grants .read_only @@ -448,7 +454,7 @@ tinybus_module::module_export_optional_static! { setup = setup, worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], - methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker"], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants"], signals = [], requires = [], optional = [], diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index dd500aca..b8558914 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -959,3 +959,56 @@ async fn local_grant_discovery_rejects_non_utf8_canonical_targets() assert!(result.is_err()); Ok(()) } + +#[tokio::test] +async fn local_grant_discovery_bounds_final_toolchain_and_extra_grants() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + std::fs::create_dir(home.path().join(".rustup"))?; + let mut extra_read_only = Vec::new(); + for index in 0..tinybox_bus::MAX_NATIVE_JAIL_GRANTS { + let path = home.path().join(format!("data-{index}")); + std::fs::create_dir(&path)?; + extra_read_only.push(path.to_str().ok_or("grant is not UTF-8")?.into()); + } + let result = BoxService::default() + .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("home is not UTF-8")?.into()), + config: tinybox_bus::LocalGrantConfig { + extra_read_only, + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths::default(), + }) + .await; + assert!( + matches!(result, Err(tinybus::Error::MethodFailed { ref name, .. }) if name.as_str() == tinybox_bus::RESOURCE_LIMIT) + ); + Ok(()) +} + +#[tokio::test] +async fn local_grant_discovery_accepts_exactly_the_final_limit() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let mut extra_read_only = Vec::new(); + for index in 0..tinybox_bus::MAX_NATIVE_JAIL_GRANTS { + let path = home.path().join(format!("data-{index}")); + std::fs::create_dir(&path)?; + extra_read_only.push(path.to_str().ok_or("grant is not UTF-8")?.into()); + } + let result = BoxService::default() + .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("home is not UTF-8")?.into()), + config: tinybox_bus::LocalGrantConfig { + toolchain_homes: false, + extra_read_only, + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths::default(), + }) + .await?; + assert_eq!(result.read_only.len(), tinybox_bus::MAX_NATIVE_JAIL_GRANTS); + assert_eq!(result.read_write, Vec::::new()); + Ok(()) +} diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs new file mode 100644 index 00000000..557c8550 --- /dev/null +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -0,0 +1,118 @@ +//! Unconditional compiled-artifact admission and local grant discovery proof. + +use std::time::Duration; +use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compiled_manifest_admits_and_executes_local_grant_discovery() +-> Result<(), Box> { + let artifact = compiled_artifact()?; + assert!( + artifact.is_file(), + "compiled TinyBox artifact missing: {}", + artifact.display() + ); + let bus = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(bus.clone()); + let modules = ModuleHost::new(broker); + let loaded = modules.load_file(&artifact)?; + let declared: Vec<_> = loaded + .manifest + .provides + .iter() + .flat_map(|interface| interface.methods.iter()) + .map(tinybus::MemberName::as_str) + .collect(); + assert_eq!(declared, tinybox_bus::METHODS); + let client = Connection::connect(bus.connect().await?).await?; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if client + .list_names() + .await? + .iter() + .any(|name| name.as_str() == tinybox_bus::INTERFACE) + { + return Ok::<(), tinybus::Error>(()); + } + tokio::task::yield_now().await; + } + }) + .await??; + let proxy = client.proxy( + tinybox_bus::INTERFACE, + tinybox_bus::OBJECT_PATH, + tinybox_bus::INTERFACE, + )?; + let home = tempfile::tempdir()?; + std::fs::create_dir(home.path().join("data"))?; + let result: tinybox_bus::LocalGrants = proxy + .call( + "ResolveLocalGrants", + (tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("home is not UTF-8")?.into()), + config: tinybox_bus::LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/data".into()], + ..tinybox_bus::LocalGrantConfig::default() + }, + protected: tinybox_bus::ProtectedPaths::default(), + },), + ) + .await?; + assert_eq!( + result.read_only, + vec![ + home.path() + .join("data") + .canonicalize()? + .to_str() + .ok_or("grant is not UTF-8")? + ] + ); + assert_eq!(result.read_write, Vec::::new()); + proxy.call::<()>("Shutdown", ()).await?; + broker_task.abort(); + Ok(()) +} + +fn compiled_artifact() -> Result> { + let executable = std::env::current_exe()?; + let dependencies = executable + .parent() + .ok_or("integration test has no parent directory")?; + let filename = format!( + "{}tinybox{}", + std::env::consts::DLL_PREFIX, + std::env::consts::DLL_SUFFIX + ); + // All-feature/static-link builds omit the dynamic ABI. Always build the + // dynamic variant separately: another validation command may replace the + // ordinary dependency artifact after Cargo releases its compilation lock. + let target = dependencies + .parent() + .and_then(std::path::Path::parent) + .ok_or("integration test has no target directory")? + .join("module-dynamic-proof"); + let status = std::process::Command::new(env!("CARGO")) + .args([ + "build", + "--locked", + "--offline", + "-p", + "tinybox-module", + "--no-default-features", + "--lib", + ]) + .arg("--manifest-path") + .arg(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .status()?; + assert!( + status.success(), + "default-feature dynamic artifact build failed: {status}" + ); + Ok(target.join("debug").join(filename)) +} From 5915d5ee355c9f0410c1ea8dfe2ccb0ffa97f0ab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:32:08 +0300 Subject: [PATCH 04/38] fix(jail): preserve protected aliases and reserve procfs grants Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 22 ++++++-- crates/tinybox-jail/src/grants_tests.rs | 53 +++++++++++++++++++ .../tests/grants_manifest_e2e.rs | 43 ++++++++++++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 01d93971..1a383a5c 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -110,14 +110,20 @@ impl<'a> Builder<'a> { } /// Canonicalize `path` and return it only if it exists and clears the - /// credential floor. Canonicalizing first means a symlink into `~/.ssh` - /// is judged by its target. + /// credential floor on both its requested name and resolved target. fn admit(&self, path: &Path, source: &str) -> Option { + if self.refuses_path(path) { + log::warn!( + "refused {source} grant reaching protected paths: {}", + path.display() + ); + return None; + } let Ok(canonical) = path.canonicalize() else { log::debug!("skipped missing {source} grant: {}", path.display()); return None; }; - if self.is_protected(&canonical) || self.exposes_credentials(&canonical) { + if self.refuses_path(&canonical) { log::warn!( "refused {source} grant reaching protected paths: {}", canonical.display() @@ -127,6 +133,16 @@ impl<'a> Builder<'a> { Some(canonical) } + fn refuses_path(&self, path: &Path) -> bool { + let normalized = normalized_floor_path(&path.to_string_lossy()); + // Procfs is reserved for the dedicated read-only toggle, including + // ancestors whose recursive grant would expose it. + let exposes_proc = Path::new("/proc").exists() + && (same_or_descendant(&normalized, "/proc") + || same_or_descendant("/proc", &normalized)); + self.is_protected(path) || self.exposes_credentials(path) || exposes_proc + } + /// True when `path` is, contains, or sits inside a credential directory. fn exposes_credentials(&self, path: &Path) -> bool { let path = normalized_floor_path(&path.to_string_lossy()); diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 31f65d6c..83671b05 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -478,3 +478,56 @@ fn credential_root_ancestors_match_case_and_separator_styles() { assert!(builder.exposes_credentials(Path::new(r"C:\USERS\floor-fixture\.SSH\id_rsa"))); assert!(!builder.exposes_credentials(Path::new(r"C:\Users\floor-fixture-other"))); } + +#[test] +#[cfg(unix)] +fn protected_credential_alias_does_not_grant_its_neutral_target() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir(home.path().join("dotfiles"))?; + fs::write(home.path().join("dotfiles/npmrc"), "fixture")?; + std::os::unix::fs::symlink( + home.path().join("dotfiles/npmrc"), + home.path().join(".npmrc"), + )?; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/.npmrc".into()], + ..LocalGrantConfig::default() + }; + let protected = ProtectedPaths { + components: vec![".npmrc".into()], + ..ProtectedPaths::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); + assert_eq!(grants.read_only, Vec::::new()); + Ok(()) +} + +#[test] +#[cfg(target_os = "linux")] +fn procfs_extras_cannot_bypass_the_dedicated_toggle_with_an_empty_floor() { + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["/proc".into(), "/proc/self".into()], + extra_read_write: vec!["/proc".into(), "/proc/self".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(None, &config, &ProtectedPaths::default()); + assert_eq!(grants.read_only, Vec::::new()); + assert_eq!(grants.read_write, Vec::::new()); +} + +#[test] +#[cfg(target_os = "linux")] +fn procfs_toggle_grants_read_only_access_even_when_extras_ask_for_writes() { + let config = LocalGrantConfig { + toolchain_homes: false, + allow_proc: true, + extra_read_write: vec!["/proc".into(), "/proc/self".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(None, &config, &ProtectedPaths::default()); + assert_eq!(grants.read_only, vec![PathBuf::from("/proc")]); + assert_eq!(grants.read_write, Vec::::new()); +} diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 557c8550..cb7eec9a 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -6,7 +6,20 @@ use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory: #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn compiled_manifest_admits_and_executes_local_grant_discovery() -> Result<(), Box> { - let artifact = compiled_artifact()?; + let built_artifact = compiled_artifact()?; + // Cargo output inherits the checkout ACL on Windows, which need not meet + // production module admission. Stage the real artifact with private ACLs. + let staged = tempfile::Builder::new() + .prefix("admitted-module-") + .tempdir_in(built_artifact.parent().ok_or("artifact has no directory")?)?; + #[cfg(windows)] + secure_staging_directory(staged.path())?; + let artifact = staged.path().join( + built_artifact + .file_name() + .ok_or("artifact has no filename")?, + ); + std::fs::copy(&built_artifact, &artifact)?; assert!( artifact.is_file(), "compiled TinyBox artifact missing: {}", @@ -116,3 +129,31 @@ fn compiled_artifact() -> Result> ); Ok(target.join("debug").join(filename)) } + +#[cfg(windows)] +fn secure_staging_directory(path: &std::path::Path) -> Result<(), Box> { + let identity = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value", + ]) + .output()?; + assert!( + identity.status.success(), + "current-user SID query failed: {identity:?}" + ); + let sid = String::from_utf8(identity.stdout)?; + let grant = format!("*{}:(OI)(CI)F", sid.trim()); + let acl = std::process::Command::new("icacls") + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .arg(grant) + .output()?; + assert!( + acl.status.success(), + "private module staging ACL failed: {acl:?}" + ); + Ok(()) +} From 4adc410ea89fe2ddce416afc62af551d8cbccd8f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:37:15 +0300 Subject: [PATCH 05/38] chore: files changed crates/tinybox-jail/src/grants.rs,crates/tinybox-jail/src/grants_tests.rs,crate Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 8 ++- crates/tinybox-jail/src/grants_tests.rs | 60 +++++++++++++++++-- .../src/tinybus_module/README.md | 6 +- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 1a383a5c..81385ded 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -199,14 +199,16 @@ impl<'a> Builder<'a> { /// Grant only the parts of `~/.cargo` that jailed Cargo needs. The root is /// never writable: `bin` and Cargo configuration run later outside the /// jail, so writable access there would persist an escape for host tools. + /// Shared dependency sources are also read-only: a cached build script can + /// execute during a later host build. Fetches need a disposable Cargo home. fn add_cargo_home(&mut self, cargo: &Path) { if !cargo.is_dir() { return; } log::debug!("[sandbox:grants] granting ~/.cargo parts with host code read-only"); self.read_only(&cargo.join("bin"), "toolchain"); - self.read_write(&cargo.join("registry"), "toolchain"); - self.read_write(&cargo.join("git"), "toolchain"); + self.read_only(&cargo.join("registry"), "toolchain"); + self.read_only(&cargo.join("git"), "toolchain"); for f in CARGO_READ_ONLY_FILES { self.read_only(&cargo.join(f), "toolchain"); } @@ -233,7 +235,7 @@ impl<'a> Builder<'a> { depth: usize, visited: &mut HashSet, ) { - let Ok(canonical) = file.canonicalize() else { + let Some(canonical) = self.admit(file, "gitconfig") else { return; }; if !canonical.is_file() || !visited.insert(canonical.clone()) { diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 83671b05..64dec671 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -102,8 +102,8 @@ fn toolchain_homes_are_granted_when_they_exist() -> Result<(), Box::new()); } + +#[test] +fn discovered_cargo_dependency_sources_cannot_be_poisoned_for_later_host_builds() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + for cache in ["git/checkouts/dependency", "registry/src/dependency"] { + let source = home.path().join(".cargo").join(cache); + fs::create_dir_all(&source)?; + fs::write(source.join("build.rs"), "fn main() {}")?; + } + let grants = resolve_local_jail_grants( + Some(home.path()), + &LocalGrantConfig::default(), + &ProtectedPaths::default(), + ); + for cache in ["git", "registry"] { + let source = home.path().join(".cargo").join(cache).canonicalize()?; + assert!(has(&grants.read_only, &source), "{grants:?}"); + assert!(!has(&grants.read_write, &source), "{grants:?}"); + } + Ok(()) +} + +#[test] +fn rejected_git_config_cannot_grant_its_nested_includes() -> Result<(), Box> +{ + let home = tempfile::tempdir()?; + fs::create_dir(home.path().join(".ssh"))?; + fs::write( + home.path().join(".gitconfig"), + "[include]\n path = ~/.ssh/private-config\n", + )?; + fs::write( + home.path().join(".ssh/private-config"), + "[include]\n path = ~/neutral-secret\n", + )?; + fs::write(home.path().join("neutral-secret"), "fixture")?; + let protected = ProtectedPaths { + components: vec![".ssh".into()], + ..ProtectedPaths::default() + }; + let grants = + resolve_local_jail_grants(Some(home.path()), &LocalGrantConfig::default(), &protected); + assert!( + !has( + &grants.read_only, + &home.path().join("neutral-secret").canonicalize()? + ), + "{grants:?}" + ); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 8141638a..ae145967 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -268,5 +268,9 @@ canonicalizes symlinks, discovers toolchain homes and Git include chains, and returns read-only and writable grants. Discovery does not start a process or make an approval decision. Credential roots exclude both descendants and ancestors; a grant on the operator home cannot expose `.ssh`. `/proc` is granted -only by the explicit `allow_proc` flag. Hosts must supply their complete floor +only by the explicit `allow_proc` flag. Both requested names and canonical +targets must clear the floor; rejected Git config files are never traversed. +Shared Cargo Git and registry dependency caches are read-only because their +build scripts can execute in later host builds. Commands that fetch dependencies +need a separately authorized disposable Cargo home. Hosts must supply their complete floor and apply authorization before using the returned grants in `StartJailedExec`. From 515b0734e997e4c139417884ff0c006175d21d24 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:39:24 +0300 Subject: [PATCH 06/38] test(grants): cover recursive ancestor grants against protected prefixes Add a test asserting that a grant on a directory containing a protected prefix is rejected, so recursive ancestor grants cannot widen access to protected paths. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 64dec671..7ef4624c 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -583,3 +583,22 @@ fn rejected_git_config_cannot_grant_its_nested_includes() -> Result<(), Box Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir(home.path().join("private"))?; + let protected = ProtectedPaths { + prefixes: vec![home.path().join("private").to_str().ok_or("non-UTF8 fixture")?.into()], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_write: vec![home.path().to_str().ok_or("non-UTF8 fixture")?.into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(None, &config, &protected); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} From 92c92c78f19c5dc262a5632cce9bfb0086d7e3a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:40:35 +0300 Subject: [PATCH 07/38] fix(grants): reject protected prefixes that are descendants of a grant The protected-prefix check only matched when the requested path was the same as or below a protected prefix, so a grant covering an ancestor of a protected directory slipped through. The comparison is now symmetric so that either direction of containment is rejected, and the existing test was reformatted to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 9 ++++----- crates/tinybox-jail/src/grants_tests.rs | 8 +++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 81385ded..28f02aa9 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -170,11 +170,10 @@ impl<'a> Builder<'a> { pair[0].eq_ignore_ascii_case(&blocked[0]) && pair[1].eq_ignore_ascii_case(&blocked[1]) }) - }) || self - .protected - .prefixes - .iter() - .any(|prefix| same_or_descendant(&normalized, &normalized_floor_path(prefix))) + }) || self.protected.prefixes.iter().any(|prefix| { + let prefix = normalized_floor_path(prefix); + same_or_descendant(&normalized, &prefix) || same_or_descendant(&prefix, &normalized) + }) } fn read_only(&mut self, path: &Path, source: &str) { diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 7ef4624c..95271b2c 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -590,7 +590,13 @@ fn protected_prefixes_also_reject_recursive_ancestor_grants() let home = tempfile::tempdir()?; fs::create_dir(home.path().join("private"))?; let protected = ProtectedPaths { - prefixes: vec![home.path().join("private").to_str().ok_or("non-UTF8 fixture")?.into()], + prefixes: vec![ + home.path() + .join("private") + .to_str() + .ok_or("non-UTF8 fixture")? + .into(), + ], ..ProtectedPaths::default() }; let config = LocalGrantConfig { From 53d586fdf656f46199b8246aba093571d57c9ee9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:48:00 +0300 Subject: [PATCH 08/38] test(grants): cover protected prefix aliases and cargo source promotion Add tests confirming that a protected prefix blocks its canonical spelling through a symlink alias, that explicit writable grants cannot promote shared cargo git and registry caches, and that git ignore patterns cannot smuggle in config include grants. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 95271b2c..72e05e3f 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -608,3 +608,43 @@ fn protected_prefixes_also_reject_recursive_ancestor_grants() assert_eq!(grants.read_write, Vec::::new()); Ok(()) } + +#[test] +#[cfg(unix)] +fn protected_prefix_alias_also_blocks_its_canonical_spelling() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let target = home.path().join("neutral"); + fs::create_dir(&target)?; + std::os::unix::fs::symlink(&target, home.path().join("protected"))?; + let protected = ProtectedPaths { prefixes: vec![home.path().join("protected").to_str().ok_or("non-UTF8 fixture")?.into()], ..ProtectedPaths::default() }; + let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec![target.to_str().ok_or("non-UTF8 fixture")?.into()], ..LocalGrantConfig::default() }; + let grants = resolve_local_jail_grants(None, &config, &protected); + assert_eq!(grants.read_only, Vec::::new()); + Ok(()) +} + +#[test] +fn explicit_writable_grants_cannot_promote_shared_cargo_sources() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + for cache in ["git", "registry"] { fs::create_dir_all(home.path().join(".cargo").join(cache))?; } + let config = LocalGrantConfig { toolchain_homes: true, extra_read_write: vec!["~/.cargo/git".into(), "~/.cargo/registry".into()], ..LocalGrantConfig::default() }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + assert_eq!(grants.read_write, Vec::::new()); + for cache in ["git", "registry"] { assert!(has(&grants.read_only, &home.path().join(".cargo").join(cache).canonicalize()?), "{grants:?}"); } + Ok(()) +} + +#[test] +fn git_ignore_patterns_cannot_add_config_include_grants() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join(".config/git"))?; + fs::write(home.path().join(".config/git/ignore"), "[include]\npath = ~/neutral-secret\n")?; + fs::write(home.path().join("neutral-secret"), "fixture")?; + let grants = resolve_local_jail_grants(Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default()); + assert!(has(&grants.read_only, &home.path().join(".config/git/ignore").canonicalize()?), "{grants:?}"); + assert!(!has(&grants.read_only, &home.path().join("neutral-secret").canonicalize()?), "{grants:?}"); + Ok(()) +} From b8f866c6a4012c59718177a201672233fb4437e3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:48:30 +0300 Subject: [PATCH 09/38] test(grants): cover credential root alias ancestor protection Add a Unix-only test asserting that when a credential root such as ~/.ssh is a symlink pointing outside the home directory, resolving local jail grants with the home directory as an extra read-only path yields no read-only grants, so the requested ancestor stays protected. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 72e05e3f..13fb8740 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -648,3 +648,17 @@ fn git_ignore_patterns_cannot_add_config_include_grants() assert!(!has(&grants.read_only, &home.path().join("neutral-secret").canonicalize()?), "{grants:?}"); Ok(()) } + +#[test] +#[cfg(unix)] +fn credential_root_alias_keeps_its_requested_ancestor_protected() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + std::os::unix::fs::symlink(outside.path(), home.path().join(".ssh"))?; + let protected = ProtectedPaths { credential_roots: vec![home.path().join(".ssh").to_str().ok_or("non-UTF8 fixture")?.into()], ..ProtectedPaths::default() }; + let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["~".into()], ..LocalGrantConfig::default() }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); + assert_eq!(grants.read_only, Vec::::new()); + Ok(()) +} From e2f8c798677011f28ae149952a13f45e0f19388f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:49:32 +0300 Subject: [PATCH 10/38] fix(jail): block writable grants reaching shared cargo sources Protected roots are now compared under both their configured spelling and their resolved target, so a symlinked root no longer lets a grant slip past the credential and prefix checks. Writable grants that would reach the shared Cargo git or registry caches are refused, and git ignore files are granted read-only directly instead of being collected as config includes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 44 +++++++++++-- crates/tinybox-jail/src/grants_tests.rs | 85 +++++++++++++++++++++---- 2 files changed, 109 insertions(+), 20 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 28f02aa9..1aae78c7 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -15,7 +15,7 @@ const HOME_READ_ONLY_DIRS: &[&str] = &[".rustup", ".nvm", ".npm"]; const CARGO_READ_ONLY_FILES: &[&str] = &["config.toml", "config", "env"]; /// Git config files read at startup, relative to `$HOME`. -const GITCONFIG_FILES: &[&str] = &[".gitconfig", ".config/git/config", ".config/git/ignore"]; +const GITCONFIG_FILES: &[&str] = &[".gitconfig", ".config/git/config"]; /// Deepest `include` chain followed when collecting git config files. const GITCONFIG_MAX_DEPTH: usize = 8; @@ -147,10 +147,8 @@ impl<'a> Builder<'a> { fn exposes_credentials(&self, path: &Path) -> bool { let path = normalized_floor_path(&path.to_string_lossy()); self.protected.credential_roots.iter().any(|root| { - let cred = PathBuf::from(root); - let cred = cred.canonicalize().unwrap_or(cred); - let cred = normalized_floor_path(&cred.to_string_lossy()); - same_or_descendant(&path, &cred) || same_or_descendant(&cred, &path) + root_spellings(Path::new(root)) + .any(|cred| same_or_descendant(&path, &cred) || same_or_descendant(&cred, &path)) }) } @@ -171,8 +169,9 @@ impl<'a> Builder<'a> { && pair[1].eq_ignore_ascii_case(&blocked[1]) }) }) || self.protected.prefixes.iter().any(|prefix| { - let prefix = normalized_floor_path(prefix); - same_or_descendant(&normalized, &prefix) || same_or_descendant(&prefix, &normalized) + root_spellings(Path::new(prefix)).any(|prefix| { + same_or_descendant(&normalized, &prefix) || same_or_descendant(&prefix, &normalized) + }) }) } @@ -186,6 +185,23 @@ impl<'a> Builder<'a> { fn read_write(&mut self, path: &Path, source: &str) { if let Some(p) = self.admit(path, source) { + let normalized = normalized_floor_path(&p.to_string_lossy()); + if self.home.is_some_and(|home| { + ["git", "registry"].into_iter().any(|cache| { + let root = home.join(".cargo").join(cache); + root.is_dir() + && root_spellings(&root).any(|root| { + same_or_descendant(&normalized, &root) + || same_or_descendant(&root, &normalized) + }) + }) + }) { + log::warn!( + "refused writable grant reaching shared Cargo sources: {}", + p.display() + ); + return; + } // A path granted writable subsumes an earlier read-only grant. self.grants.read_only.retain(|g| g != &p); self.seen.insert(p.clone()); @@ -221,6 +237,10 @@ impl<'a> Builder<'a> { if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { roots.push(Path::new(&xdg).join("git/config")); } + self.read_only(&home.join(".config/git/ignore"), "gitignore"); + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + self.read_only(&Path::new(&xdg).join("git/ignore"), "gitignore"); + } let mut visited = HashSet::new(); for root in roots { self.add_gitconfig_file(&root, home, 0, &mut visited); @@ -263,6 +283,16 @@ impl<'a> Builder<'a> { } } +// A configured protected root may itself be a symlink (for example /etc on +// macOS). Preserve its requested spelling and its existing resolved target. +fn root_spellings(root: &Path) -> impl Iterator { + std::iter::once(normalized_floor_path(&root.to_string_lossy())).chain( + root.canonicalize() + .ok() + .map(|canonical| normalized_floor_path(&canonical.to_string_lossy())), + ) +} + // Windows canonical paths may carry an extended drive or UNC namespace; // compare the underlying filesystem path, regardless of spelling or case. fn normalized_floor_path(path: &str) -> String { diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 13fb8740..c42a414c 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -617,8 +617,21 @@ fn protected_prefix_alias_also_blocks_its_canonical_spelling() let target = home.path().join("neutral"); fs::create_dir(&target)?; std::os::unix::fs::symlink(&target, home.path().join("protected"))?; - let protected = ProtectedPaths { prefixes: vec![home.path().join("protected").to_str().ok_or("non-UTF8 fixture")?.into()], ..ProtectedPaths::default() }; - let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec![target.to_str().ok_or("non-UTF8 fixture")?.into()], ..LocalGrantConfig::default() }; + let protected = ProtectedPaths { + prefixes: vec![ + home.path() + .join("protected") + .to_str() + .ok_or("non-UTF8 fixture")? + .into(), + ], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec![target.to_str().ok_or("non-UTF8 fixture")?.into()], + ..LocalGrantConfig::default() + }; let grants = resolve_local_jail_grants(None, &config, &protected); assert_eq!(grants.read_only, Vec::::new()); Ok(()) @@ -628,24 +641,57 @@ fn protected_prefix_alias_also_blocks_its_canonical_spelling() fn explicit_writable_grants_cannot_promote_shared_cargo_sources() -> Result<(), Box> { let home = tempfile::tempdir()?; - for cache in ["git", "registry"] { fs::create_dir_all(home.path().join(".cargo").join(cache))?; } - let config = LocalGrantConfig { toolchain_homes: true, extra_read_write: vec!["~/.cargo/git".into(), "~/.cargo/registry".into()], ..LocalGrantConfig::default() }; + for cache in ["git", "registry"] { + fs::create_dir_all(home.path().join(".cargo").join(cache))?; + } + let config = LocalGrantConfig { + toolchain_homes: true, + extra_read_write: vec!["~/.cargo/git".into(), "~/.cargo/registry".into()], + ..LocalGrantConfig::default() + }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); assert_eq!(grants.read_write, Vec::::new()); - for cache in ["git", "registry"] { assert!(has(&grants.read_only, &home.path().join(".cargo").join(cache).canonicalize()?), "{grants:?}"); } + for cache in ["git", "registry"] { + assert!( + has( + &grants.read_only, + &home.path().join(".cargo").join(cache).canonicalize()? + ), + "{grants:?}" + ); + } Ok(()) } #[test] -fn git_ignore_patterns_cannot_add_config_include_grants() --> Result<(), Box> { +fn git_ignore_patterns_cannot_add_config_include_grants() -> Result<(), Box> +{ let home = tempfile::tempdir()?; fs::create_dir_all(home.path().join(".config/git"))?; - fs::write(home.path().join(".config/git/ignore"), "[include]\npath = ~/neutral-secret\n")?; + fs::write( + home.path().join(".config/git/ignore"), + "[include]\npath = ~/neutral-secret\n", + )?; fs::write(home.path().join("neutral-secret"), "fixture")?; - let grants = resolve_local_jail_grants(Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default()); - assert!(has(&grants.read_only, &home.path().join(".config/git/ignore").canonicalize()?), "{grants:?}"); - assert!(!has(&grants.read_only, &home.path().join("neutral-secret").canonicalize()?), "{grants:?}"); + let grants = resolve_local_jail_grants( + Some(home.path()), + &LocalGrantConfig::default(), + &ProtectedPaths::default(), + ); + assert!( + has( + &grants.read_only, + &home.path().join(".config/git/ignore").canonicalize()? + ), + "{grants:?}" + ); + assert!( + !has( + &grants.read_only, + &home.path().join("neutral-secret").canonicalize()? + ), + "{grants:?}" + ); Ok(()) } @@ -656,8 +702,21 @@ fn credential_root_alias_keeps_its_requested_ancestor_protected() let home = tempfile::tempdir()?; let outside = tempfile::tempdir()?; std::os::unix::fs::symlink(outside.path(), home.path().join(".ssh"))?; - let protected = ProtectedPaths { credential_roots: vec![home.path().join(".ssh").to_str().ok_or("non-UTF8 fixture")?.into()], ..ProtectedPaths::default() }; - let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["~".into()], ..LocalGrantConfig::default() }; + let protected = ProtectedPaths { + credential_roots: vec![ + home.path() + .join(".ssh") + .to_str() + .ok_or("non-UTF8 fixture")? + .into(), + ], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~".into()], + ..LocalGrantConfig::default() + }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); assert_eq!(grants.read_only, Vec::::new()); Ok(()) From e45cb6cce72a79851dd3858109acd426af54badc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:50:17 +0300 Subject: [PATCH 11/38] docs(tinybus-module): document alias and cache grant rules Clarify that configured roots keep both alias and canonical spellings, and that ignore pattern files are granted without parsing configuration includes. Also note that writable extras cannot promote shared Cargo caches or expose them recursively. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index ae145967..bf496afc 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -269,8 +269,11 @@ returns read-only and writable grants. Discovery does not start a process or make an approval decision. Credential roots exclude both descendants and ancestors; a grant on the operator home cannot expose `.ssh`. `/proc` is granted only by the explicit `allow_proc` flag. Both requested names and canonical -targets must clear the floor; rejected Git config files are never traversed. +targets must clear the floor; configured roots preserve both their alias and +canonical spellings. Rejected Git config files are never traversed, and ignore +pattern files are granted directly without parsing configuration includes. Shared Cargo Git and registry dependency caches are read-only because their -build scripts can execute in later host builds. Commands that fetch dependencies +build scripts can execute in later host builds; writable extras cannot promote +those caches or recursively expose them. Commands that fetch dependencies need a separately authorized disposable Cargo home. Hosts must supply their complete floor and apply authorization before using the returned grants in `StartJailedExec`. From 7abf36be8494ba28200f32176f10db195df3b844 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:55:07 +0300 Subject: [PATCH 12/38] test(grants): cover credential floor with aliased parent directories Add a unix-only test asserting that the credential floor still protects aliased credential locations when the home directory itself is reached through a symlinked parent. The fixture nests a symlink to an outside directory under an aliased home and checks that no read-only grants are produced for either the direct or the missing-path credential root. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index c42a414c..2d790703 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -721,3 +721,23 @@ fn credential_root_alias_keeps_its_requested_ancestor_protected() assert_eq!(grants.read_only, Vec::::new()); Ok(()) } + +#[test] +#[cfg(unix)] +fn credential_floor_preserves_alias_locations_under_aliased_parent_directories() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + fs::create_dir(root.path().join("physical"))?; + std::os::unix::fs::symlink(root.path().join("physical"), root.path().join("parent-alias"))?; + let home = root.path().join("parent-alias/home"); + fs::create_dir(&home)?; + std::os::unix::fs::symlink(outside.path(), home.join(".ssh"))?; + for credential in [".ssh", "missing/.ssh"] { + let protected = ProtectedPaths { credential_roots: vec![home.join(credential).to_str().ok_or("non-UTF8 fixture")?.into()], ..ProtectedPaths::default() }; + let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["~".into()], ..LocalGrantConfig::default() }; + let grants = resolve_local_jail_grants(Some(&home), &config, &protected); + assert_eq!(grants.read_only, Vec::::new(), "{credential}"); + } + Ok(()) +} From 7545b72e2e2b1df841103fd69e07ef9d7fecc87b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:55:48 +0300 Subject: [PATCH 13/38] fix(jail): resolve protected roots through missing ancestors Protected roots whose parent directories do not exist yet were only matched by their literal spelling, so aliased locations under a symlinked parent could escape the credential floor. Root spellings now also walk up to the nearest existing ancestor, canonicalize it, and re-append the missing components before normalizing. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 28 ++++++++++++++++++++----- crates/tinybox-jail/src/grants_tests.rs | 21 ++++++++++++++++--- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 1aae78c7..165aa7cc 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -286,11 +286,29 @@ impl<'a> Builder<'a> { // A configured protected root may itself be a symlink (for example /etc on // macOS). Preserve its requested spelling and its existing resolved target. fn root_spellings(root: &Path) -> impl Iterator { - std::iter::once(normalized_floor_path(&root.to_string_lossy())).chain( - root.canonicalize() - .ok() - .map(|canonical| normalized_floor_path(&canonical.to_string_lossy())), - ) + let location = root.parent().and_then(|parent| { + let mut ancestor = parent; + let mut missing = Vec::new(); + let mut canonical = loop { + if let Ok(canonical) = ancestor.canonicalize() { + break canonical; + } + missing.push(ancestor.file_name()?); + ancestor = ancestor.parent()?; + }; + for component in missing.into_iter().rev() { + canonical.push(component); + } + canonical.push(root.file_name()?); + Some(normalized_floor_path(&canonical.to_string_lossy())) + }); + std::iter::once(normalized_floor_path(&root.to_string_lossy())) + .chain(location) + .chain( + root.canonicalize() + .ok() + .map(|canonical| normalized_floor_path(&canonical.to_string_lossy())), + ) } // Windows canonical paths may carry an extended drive or UNC namespace; diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 2d790703..5801c429 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -729,13 +729,28 @@ fn credential_floor_preserves_alias_locations_under_aliased_parent_directories() let root = tempfile::tempdir()?; let outside = tempfile::tempdir()?; fs::create_dir(root.path().join("physical"))?; - std::os::unix::fs::symlink(root.path().join("physical"), root.path().join("parent-alias"))?; + std::os::unix::fs::symlink( + root.path().join("physical"), + root.path().join("parent-alias"), + )?; let home = root.path().join("parent-alias/home"); fs::create_dir(&home)?; std::os::unix::fs::symlink(outside.path(), home.join(".ssh"))?; for credential in [".ssh", "missing/.ssh"] { - let protected = ProtectedPaths { credential_roots: vec![home.join(credential).to_str().ok_or("non-UTF8 fixture")?.into()], ..ProtectedPaths::default() }; - let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["~".into()], ..LocalGrantConfig::default() }; + let protected = ProtectedPaths { + credential_roots: vec![ + home.join(credential) + .to_str() + .ok_or("non-UTF8 fixture")? + .into(), + ], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~".into()], + ..LocalGrantConfig::default() + }; let grants = resolve_local_jail_grants(Some(&home), &config, &protected); assert_eq!(grants.read_only, Vec::::new(), "{credential}"); } From 0677075d0e5b6273e149b0798f91cd03e86d5742 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:02:49 +0300 Subject: [PATCH 14/38] test(grants): cover writable extras that target protected cargo and toolchain paths Add tests asserting that extra read-write grants cannot replace Cargo executables or launch configuration, and that automatic toolchain roots cannot be promoted through writable descendants. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 5801c429..f7fe8578 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -756,3 +756,34 @@ fn credential_floor_preserves_alias_locations_under_aliased_parent_directories() } Ok(()) } + +#[test] +fn writable_extras_cannot_replace_cargo_executables_or_launch_configuration() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join(".cargo/bin"))?; + for file in ["config.toml", "config", "env"] { + fs::write(home.path().join(".cargo").join(file), "fixture")?; + } + let config = LocalGrantConfig { + extra_read_write: ["bin", "config.toml", "config", "env"].into_iter().map(|entry| format!("~/.cargo/{entry}")).collect(), + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} + +#[test] +fn automatic_toolchain_roots_cannot_be_promoted_through_writable_descendants() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + for root in [".rustup", ".nvm", ".npm"] { fs::create_dir_all(home.path().join(root).join("nested"))?; } + let config = LocalGrantConfig { + extra_read_write: [".rustup", ".nvm", ".npm"].into_iter().map(|root| format!("~/{root}/nested")).collect(), + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} From c3ce0b4c506792f2eb4af24130f3b50e295f33b8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:03:36 +0300 Subject: [PATCH 15/38] fix(jail): block writable grants reaching fixed host toolchain state Read-only grants for toolchain, gitignore and gitconfig paths are now tracked as fixed, and writable grants that reach them are refused. This closes a hole where a writable grant on an ancestor directory could replace Cargo executables or poison discovered git configuration. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 63 ++++++++++++++++--------- crates/tinybox-jail/src/grants_tests.rs | 33 +++++++++++-- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index 165aa7cc..ef797ccb 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -44,11 +44,11 @@ pub fn resolve_local_jail_grants( if let Some(h) = home.as_deref() { b.add_cargo_home(&h.join(".cargo")); for dir in HOME_READ_ONLY_DIRS { - b.read_only(&h.join(dir), "toolchain"); + b.fixed_read_only(&h.join(dir), "toolchain"); } } for dir in SYSTEM_TOOLCHAIN_DIRS { - b.read_only(Path::new(dir), "toolchain"); + b.fixed_read_only(Path::new(dir), "toolchain"); } if let Some(h) = home.as_deref() { b.add_gitconfig(h); @@ -82,6 +82,7 @@ struct Builder<'a> { protected: &'a ProtectedPaths, grants: JailGrants, seen: HashSet, + fixed_read_only: Vec, } impl<'a> Builder<'a> { @@ -91,6 +92,7 @@ impl<'a> Builder<'a> { protected, grants: JailGrants::default(), seen: HashSet::new(), + fixed_read_only: Vec::new(), } } @@ -176,28 +178,43 @@ impl<'a> Builder<'a> { } fn read_only(&mut self, path: &Path, source: &str) { - if let Some(p) = self.admit(path, source) - && self.seen.insert(p.clone()) - { - self.grants.read_only.push(p); + self.add_read_only(path, source, false); + } + + fn fixed_read_only(&mut self, path: &Path, source: &str) { + self.add_read_only(path, source, true); + } + + 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); + } } } fn read_write(&mut self, path: &Path, source: &str) { if let Some(p) = self.admit(path, source) { let normalized = normalized_floor_path(&p.to_string_lossy()); - if self.home.is_some_and(|home| { - ["git", "registry"].into_iter().any(|cache| { - let root = home.join(".cargo").join(cache); - root.is_dir() - && root_spellings(&root).any(|root| { - same_or_descendant(&normalized, &root) - || same_or_descendant(&root, &normalized) - }) + let reaches = |root: &Path| { + root_spellings(root).any(|root| { + same_or_descendant(&normalized, &root) || same_or_descendant(&root, &normalized) }) - }) { + }; + let exposes_fixed = self.fixed_read_only.iter().any(|root| reaches(root)); + let exposes_cargo = self.home.is_some_and(|home| { + ["bin", "git", "registry"] + .into_iter() + .chain(CARGO_READ_ONLY_FILES.iter().copied()) + .any(|entry| reaches(&home.join(".cargo").join(entry))) + }); + if exposes_fixed || exposes_cargo { log::warn!( - "refused writable grant reaching shared Cargo sources: {}", + "refused writable grant reaching fixed host toolchain state: {}", p.display() ); return; @@ -221,11 +238,11 @@ impl<'a> Builder<'a> { return; } log::debug!("[sandbox:grants] granting ~/.cargo parts with host code read-only"); - self.read_only(&cargo.join("bin"), "toolchain"); - self.read_only(&cargo.join("registry"), "toolchain"); - self.read_only(&cargo.join("git"), "toolchain"); + self.fixed_read_only(&cargo.join("bin"), "toolchain"); + self.fixed_read_only(&cargo.join("registry"), "toolchain"); + self.fixed_read_only(&cargo.join("git"), "toolchain"); for f in CARGO_READ_ONLY_FILES { - self.read_only(&cargo.join(f), "toolchain"); + self.fixed_read_only(&cargo.join(f), "toolchain"); } } @@ -237,9 +254,9 @@ impl<'a> Builder<'a> { if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { roots.push(Path::new(&xdg).join("git/config")); } - self.read_only(&home.join(".config/git/ignore"), "gitignore"); + self.fixed_read_only(&home.join(".config/git/ignore"), "gitignore"); if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { - self.read_only(&Path::new(&xdg).join("git/ignore"), "gitignore"); + self.fixed_read_only(&Path::new(&xdg).join("git/ignore"), "gitignore"); } let mut visited = HashSet::new(); for root in roots { @@ -260,7 +277,7 @@ impl<'a> Builder<'a> { if !canonical.is_file() || !visited.insert(canonical.clone()) { return; } - self.read_only(&canonical, "gitconfig"); + self.fixed_read_only(&canonical, "gitconfig"); if depth >= GITCONFIG_MAX_DEPTH { return; } diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index f7fe8578..1efd5717 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -181,6 +181,7 @@ fn cargo_home_with_registry_credentials_is_granted_piecewise() fn a_writable_grant_subsumes_the_same_path_read_only() -> Result<(), Box> { let home = fake_home()?; let cfg = LocalGrantConfig { + toolchain_homes: false, extra_read_only: vec!["~/.npm".into()], extra_read_write: vec!["~/.npm".into()], ..LocalGrantConfig::default() @@ -766,7 +767,10 @@ fn writable_extras_cannot_replace_cargo_executables_or_launch_configuration() fs::write(home.path().join(".cargo").join(file), "fixture")?; } let config = LocalGrantConfig { - extra_read_write: ["bin", "config.toml", "config", "env"].into_iter().map(|entry| format!("~/.cargo/{entry}")).collect(), + extra_read_write: ["bin", "config.toml", "config", "env"] + .into_iter() + .map(|entry| format!("~/.cargo/{entry}")) + .collect(), ..LocalGrantConfig::default() }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); @@ -778,9 +782,32 @@ fn writable_extras_cannot_replace_cargo_executables_or_launch_configuration() fn automatic_toolchain_roots_cannot_be_promoted_through_writable_descendants() -> Result<(), Box> { let home = tempfile::tempdir()?; - for root in [".rustup", ".nvm", ".npm"] { fs::create_dir_all(home.path().join(root).join("nested"))?; } + for root in [".rustup", ".nvm", ".npm"] { + fs::create_dir_all(home.path().join(root).join("nested"))?; + } + let config = LocalGrantConfig { + extra_read_write: [".rustup", ".nvm", ".npm"] + .into_iter() + .map(|root| format!("~/{root}/nested")) + .collect(), + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn discovered_git_configuration_cannot_be_poisoned_via_its_target_ancestor() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let dotfiles = home.path().join("dotfiles"); + fs::create_dir(&dotfiles)?; + fs::write(dotfiles.join("gitconfig"), "[core]\n sshCommand = ssh\n")?; + std::os::unix::fs::symlink(dotfiles.join("gitconfig"), home.path().join(".gitconfig"))?; let config = LocalGrantConfig { - extra_read_write: [".rustup", ".nvm", ".npm"].into_iter().map(|root| format!("~/{root}/nested")).collect(), + extra_read_write: vec!["~/dotfiles".into()], ..LocalGrantConfig::default() }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); From 3e1c67e79182340b907da56a63c1a39b37a9b527 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:04:02 +0300 Subject: [PATCH 16/38] docs(tinybox-module): document toolchain and git config protections Clarify that automatically discovered read-only toolchains and Git configuration cannot be promoted by writable extras, and that Cargo executables and launch configuration stay protected even when discovery is disabled. Mutable toolchain operations require separately authorized disposable state. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index bf496afc..13128d98 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -274,6 +274,10 @@ canonical spellings. Rejected Git config files are never traversed, and ignore pattern files are granted directly without parsing configuration includes. Shared Cargo Git and registry dependency caches are read-only because their build scripts can execute in later host builds; writable extras cannot promote -those caches or recursively expose them. Commands that fetch dependencies +those caches or recursively expose them. Automatically discovered read-only +toolchains and Git configuration also cannot be promoted by writable extras; +Cargo executables and launch configuration remain protected even if discovery +is disabled. Mutable toolchain operations need separately authorized disposable +state. Commands that fetch dependencies need a separately authorized disposable Cargo home. Hosts must supply their complete floor and apply authorization before using the returned grants in `StartJailedExec`. From abe63a09d1d226095bb413826bf505a672fe9555 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:17:02 +0300 Subject: [PATCH 17/38] test(jail): cover protected paths under directory extras Add tests asserting that directory extras cannot expose nested protected components or component pairs, including when a directory extra is reached through a symlink alias. These lock in that grants stay empty rather than leaking protected files. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 1efd5717..825985c9 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -814,3 +814,70 @@ fn discovered_git_configuration_cannot_be_poisoned_via_its_target_ancestor() assert_eq!(grants.read_write, Vec::::new()); Ok(()) } + +#[test] +fn directory_extras_cannot_expose_nested_protected_components() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join("project/nested"))?; + fs::write(home.path().join("project/nested/.npmrc"), "secret")?; + let protected = ProtectedPaths { + components: vec![".npmrc".into()], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/project".into()], + extra_read_write: vec!["~/project".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); + assert_eq!(grants, JailGrants::default()); + Ok(()) +} + +#[test] +fn directory_extras_cannot_expose_nested_protected_component_pairs() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join("project/nested/.docker"))?; + fs::write(home.path().join("project/nested/.docker/config.json"), "secret")?; + let protected = ProtectedPaths { + component_pairs: vec![[".docker".into(), "config.json".into()]], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/project".into()], + ..LocalGrantConfig::default() + }; + assert_eq!( + resolve_local_jail_grants(Some(home.path()), &config, &protected), + JailGrants::default() + ); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn aliased_directory_extras_preserve_logical_parent_component_pairs() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir(home.path().join("neutral"))?; + fs::write(home.path().join("neutral/credentials.toml"), "secret")?; + std::os::unix::fs::symlink(home.path().join("neutral"), home.path().join(".cargo"))?; + let protected = ProtectedPaths { + component_pairs: vec![[".cargo".into(), "credentials.toml".into()]], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec!["~/.cargo".into()], + ..LocalGrantConfig::default() + }; + assert_eq!( + resolve_local_jail_grants(Some(home.path()), &config, &protected), + JailGrants::default() + ); + Ok(()) +} From 05b9d2e191156f4eaf3bea0b96c1f389f5d48c0e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:17:54 +0300 Subject: [PATCH 18/38] feat(jail): inspect recursive grants for protected entries Recursive grants are now walked at discovery time and refused when the subtree contains a protected component or component pair, so a directory grant can no longer expose secrets nested beneath it. The walk never follows nested symlinks, checks symlink names before their targets, and fails closed on errors or when entry and depth limits are exceeded. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 98 +++++++++++++++++++++++-- crates/tinybox-jail/src/grants_tests.rs | 54 +++++++++++++- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index ef797ccb..a82dd2e5 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -20,6 +20,10 @@ const GITCONFIG_FILES: &[&str] = &[".gitconfig", ".config/git/config"]; /// Deepest `include` chain followed when collecting git config files. const GITCONFIG_MAX_DEPTH: usize = 8; +/// Bound admission work even for attacker-controlled directory trees. +const SUBTREE_MAX_ENTRIES: usize = 1_000_000; +const SUBTREE_MAX_DEPTH: usize = 128; + /// Paths the jail is granted beyond the workspace root, all canonical and /// existing. #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -132,7 +136,82 @@ impl<'a> Builder<'a> { ); return None; } - Some(canonical) + match self.subtree_is_clear(path, &canonical, SUBTREE_MAX_ENTRIES, SUBTREE_MAX_DEPTH) { + Ok(true) => Some(canonical), + Ok(false) => { + log::warn!( + "refused {source} grant containing protected entries: {}", + path.display() + ); + None + } + Err(error) => { + log::warn!( + "refused uninspectable {source} grant {}: {error}", + path.display() + ); + None + } + } + } + + /// Inspect recursive grants at discovery time. Never follow nested + /// symlinks: recursive kernel rules cover this directory tree, not targets + /// outside it. Check symlink names themselves before inspecting their type. + /// Carry the requested spelling too, so an aliased `.cargo` still protects + /// its `credentials.toml` child. Errors and traversal limits fail closed. + fn subtree_is_clear( + &self, + requested: &Path, + canonical: &Path, + max_entries: usize, + max_depth: usize, + ) -> std::io::Result { + if self.protected.components.is_empty() && self.protected.component_pairs.is_empty() { + return Ok(true); + } + if !std::fs::metadata(canonical)?.is_dir() { + return Ok(true); + } + let mut stack = vec![( + std::fs::read_dir(canonical)?, + canonical.to_path_buf(), + requested.to_path_buf(), + )]; + let mut visited = 0; + while let Some((entries, physical, logical)) = stack.last_mut() { + let Some(entry) = entries.next() else { + stack.pop(); + continue; + }; + let entry = entry?; + if visited == max_entries { + return Err(std::io::Error::other( + "protected-path inspection entry limit exceeded", + )); + } + visited += 1; + let physical_child = physical.join(entry.file_name()); + let logical_child = logical.join(entry.file_name()); + if self.has_protected_components(&physical_child) + || self.has_protected_components(&logical_child) + { + return Ok(false); + } + if entry.file_type()?.is_dir() { + if stack.len() >= max_depth { + return Err(std::io::Error::other( + "protected-path inspection depth limit exceeded", + )); + } + stack.push(( + std::fs::read_dir(&physical_child)?, + physical_child, + logical_child, + )); + } + } + Ok(true) } fn refuses_path(&self, path: &Path) -> bool { @@ -154,7 +233,7 @@ impl<'a> Builder<'a> { }) } - fn is_protected(&self, path: &Path) -> bool { + fn has_protected_components(&self, path: &Path) -> bool { let normalized = normalized_floor_path(&path.to_string_lossy()); let components: Vec<_> = normalized .split('/') @@ -170,13 +249,20 @@ impl<'a> Builder<'a> { pair[0].eq_ignore_ascii_case(&blocked[0]) && pair[1].eq_ignore_ascii_case(&blocked[1]) }) - }) || self.protected.prefixes.iter().any(|prefix| { - root_spellings(Path::new(prefix)).any(|prefix| { - same_or_descendant(&normalized, &prefix) || same_or_descendant(&prefix, &normalized) - }) }) } + fn is_protected(&self, path: &Path) -> bool { + let normalized = normalized_floor_path(&path.to_string_lossy()); + self.has_protected_components(path) + || self.protected.prefixes.iter().any(|prefix| { + root_spellings(Path::new(prefix)).any(|prefix| { + same_or_descendant(&normalized, &prefix) + || same_or_descendant(&prefix, &normalized) + }) + }) + } + fn read_only(&mut self, path: &Path, source: &str) { self.add_read_only(path, source, false); } diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index 825985c9..f05476e7 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -841,7 +841,10 @@ fn directory_extras_cannot_expose_nested_protected_component_pairs() -> Result<(), Box> { let home = tempfile::tempdir()?; fs::create_dir_all(home.path().join("project/nested/.docker"))?; - fs::write(home.path().join("project/nested/.docker/config.json"), "secret")?; + fs::write( + home.path().join("project/nested/.docker/config.json"), + "secret", + )?; let protected = ProtectedPaths { component_pairs: vec![[".docker".into(), "config.json".into()]], ..ProtectedPaths::default() @@ -881,3 +884,52 @@ fn aliased_directory_extras_preserve_logical_parent_component_pairs() ); Ok(()) } + +#[test] +fn subtree_inspection_accepts_safe_trees_and_fails_closed_at_its_bounds() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + fs::write(root.path().join("safe"), "fixture")?; + let protected = ProtectedPaths { + components: vec![".npmrc".into()], + ..ProtectedPaths::default() + }; + let builder = Builder::new(None, &protected); + assert!(builder.subtree_is_clear(root.path(), root.path(), 1, 1)?); + assert!( + builder + .subtree_is_clear(root.path(), root.path(), 0, 1) + .is_err() + ); + fs::create_dir(root.path().join("nested"))?; + assert!( + builder + .subtree_is_clear(root.path(), root.path(), 2, 1) + .is_err() + ); + assert!(builder.subtree_is_clear(root.path(), root.path(), 2, 2)?); + let missing = root.path().join("missing"); + assert!( + builder + .subtree_is_clear(&missing, &missing, 10, 10) + .is_err() + ); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn subtree_inspection_does_not_follow_cycles_but_rejects_protected_symlink_names() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink(root.path(), root.path().join("cycle"))?; + let protected = ProtectedPaths { + components: vec![".npmrc".into()], + ..ProtectedPaths::default() + }; + let builder = Builder::new(None, &protected); + assert!(builder.subtree_is_clear(root.path(), root.path(), 1, 1)?); + std::os::unix::fs::symlink(root.path().join("missing"), root.path().join(".npmrc"))?; + assert!(!builder.subtree_is_clear(root.path(), root.path(), 2, 1)?); + Ok(()) +} From 9a7c6e9064185c060714881fb2d84e4346a6f3a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:18:20 +0300 Subject: [PATCH 19/38] test(grants): drop system path assertions from toolchain homes test The test no longer asserts that /usr/local and /opt are granted read-only when they exist, since those paths are not part of the toolchain home grants being verified. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index f05476e7..f23761af 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -117,11 +117,6 @@ fn toolchain_homes_are_granted_when_they_exist() -> Result<(), Box Date: Sun, 11 Oct 2026 15:18:48 +0300 Subject: [PATCH 20/38] test(grants): cover symlink bypass of subtree pair protection Add a test asserting that a directory symlink pointing at a protected component pair does not let the pair be read, so the protection cannot be bypassed by aliasing the protected directory. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index f23761af..c27d3279 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -928,3 +928,27 @@ fn subtree_inspection_does_not_follow_cycles_but_rejects_protected_symlink_names assert!(!builder.subtree_is_clear(root.path(), root.path(), 2, 1)?); Ok(()) } + +#[test] +#[cfg(unix)] +fn subtree_pair_protection_cannot_be_bypassed_by_a_directory_symlink() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + fs::create_dir(root.path().join("neutral"))?; + fs::write(root.path().join("neutral/config.json"), "secret")?; + std::os::unix::fs::symlink(root.path().join("neutral"), root.path().join(".docker"))?; + let protected = ProtectedPaths { + component_pairs: vec![[".docker".into(), "config.json".into()]], + ..ProtectedPaths::default() + }; + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec![root.path().to_str().ok_or("non-UTF8 fixture")?.into()], + ..LocalGrantConfig::default() + }; + assert_eq!( + resolve_local_jail_grants(None, &config, &protected), + JailGrants::default() + ); + Ok(()) +} From 08aa55f80d51a38c2e00e02ace8710f21dc536fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:19:21 +0300 Subject: [PATCH 21/38] fix(jail): reject pair-prefix symlinks in protected path scan A symlink named as the first component of a protected pair could alias a directory elsewhere in the same grant and expose its protected child, so such entries are now refused rather than followed. The tinybus module README documents the recursive inspection rules and the snapshot caveat for hosts. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 16 +++++++++++++++- .../tinybox-module/src/tinybus_module/README.md | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs index a82dd2e5..496272ca 100644 --- a/crates/tinybox-jail/src/grants.rs +++ b/crates/tinybox-jail/src/grants.rs @@ -198,7 +198,21 @@ impl<'a> Builder<'a> { { return Ok(false); } - if entry.file_type()?.is_dir() { + let file_type = entry.file_type()?; + // A pair-prefix symlink could alias a directory elsewhere inside + // this same grant, exposing its protected child under the alias. + // Reject it conservatively rather than following links or cycles. + if file_type.is_symlink() + && self.protected.component_pairs.iter().any(|pair| { + entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(&pair[0]) + }) + { + return Ok(false); + } + if file_type.is_dir() { if stack.len() >= max_depth { return Err(std::io::Error::other( "protected-path inspection depth limit exceeded", diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 13128d98..f612810c 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -281,3 +281,13 @@ is disabled. Mutable toolchain operations need separately authorized disposable state. Commands that fetch dependencies need a separately authorized disposable Cargo home. Hosts must supply their complete floor and apply authorization before using the returned grants in `StartJailedExec`. + +Directory grants are inspected recursively for protected component names and +component pairs before admission. The scan preserves requested alias names, +does not follow nested symlinks, and rejects symlinks named as a protected +pair's first component. Inspection errors, more than 1,000,000 entries, or +more than 128 directory levels refuse the grant. This also applies to automatic +system toolchain roots, which may therefore be omitted. Discovery is a +point-in-time snapshot: hosts must prevent concurrent mutation and creation of +protected entries within a granted tree, or use isolated disposable state. +It does not install a kernel deny rule for filenames created later. From 67bb2094e6af028258cb1b1f77757684e2ffa272 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:19:57 +0300 Subject: [PATCH 22/38] test(jail): cover git include comments and quoted markers Add tests asserting that unquoted trailing comments in git include paths are stripped while quoted markers are preserved, and that an include with a trailing comment still grants the referenced file. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants_tests.rs | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants_tests.rs index c27d3279..ee1f63c8 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants_tests.rs @@ -952,3 +952,29 @@ fn subtree_pair_protection_cannot_be_bypassed_by_a_directory_symlink() ); Ok(()) } + +#[test] +fn git_includes_strip_unquoted_comments_but_preserve_quoted_markers() { + assert_eq!( + include_paths(r#"[include] +path = ~/work.conf # local +path = other.conf ; explanation +path = "quoted#name;value.conf" # trailing +path = prefix"quoted#part"suffix.conf ; trailing +"#), + ["~/work.conf", "other.conf", "quoted#name;value.conf", "prefixquoted#partsuffix.conf"] + ); +} + +#[test] +fn git_include_with_a_trailing_comment_grants_the_actual_file() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::write(home.path().join(".gitconfig"), "[include]\npath = ~/work.conf # local\n")?; + fs::write(home.path().join("work.conf"), "[core]\n")?; + let grants = resolve_local_jail_grants( + Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default() + ); + assert!(has(&grants.read_only, &canon(&home.path().join("work.conf"))?)); + Ok(()) +} From a79534f404de6e156eb3943fe93bdef530dbe376 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:21:07 +0300 Subject: [PATCH 23/38] refactor(grants): split grant resolution into focused submodules The grant discovery logic and its tests were moved out of the monolithic grants.rs into a grants/ module with separate files for the floor checks, git config handling, inspection, and shared types. This keeps each concern readable and makes the protected-path floor easier to audit in isolation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants.rs | 485 ------------------ crates/tinybox-jail/src/grants/floor.rs | 107 ++++ crates/tinybox-jail/src/grants/git_config.rs | 129 +++++ crates/tinybox-jail/src/grants/inspection.rs | 78 +++ crates/tinybox-jail/src/grants/mod.rs | 224 ++++++++ .../{grants_tests.rs => grants/mod_tests.rs} | 27 +- crates/tinybox-jail/src/grants/types.rs | 12 + 7 files changed, 571 insertions(+), 491 deletions(-) delete mode 100644 crates/tinybox-jail/src/grants.rs create mode 100644 crates/tinybox-jail/src/grants/floor.rs create mode 100644 crates/tinybox-jail/src/grants/git_config.rs create mode 100644 crates/tinybox-jail/src/grants/inspection.rs create mode 100644 crates/tinybox-jail/src/grants/mod.rs rename crates/tinybox-jail/src/{grants_tests.rs => grants/mod_tests.rs} (98%) create mode 100644 crates/tinybox-jail/src/grants/types.rs diff --git a/crates/tinybox-jail/src/grants.rs b/crates/tinybox-jail/src/grants.rs deleted file mode 100644 index 496272ca..00000000 --- a/crates/tinybox-jail/src/grants.rs +++ /dev/null @@ -1,485 +0,0 @@ -//! Discover canonical local toolchain grants while preserving a host-supplied protected-path floor. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use tinybox_bus::{LocalGrantConfig, ProtectedPaths}; - -/// System toolchain roots granted read-only when present. -const SYSTEM_TOOLCHAIN_DIRS: &[&str] = &["/usr/local", "/opt"]; - -/// `$HOME`-relative toolchain homes granted read-only when present. -const HOME_READ_ONLY_DIRS: &[&str] = &[".rustup", ".nvm", ".npm"]; - -/// Files Cargo needs read access to inside `~/.cargo` when the directory also -/// holds registry credentials and so cannot be granted whole. -const CARGO_READ_ONLY_FILES: &[&str] = &["config.toml", "config", "env"]; - -/// Git config files read at startup, relative to `$HOME`. -const GITCONFIG_FILES: &[&str] = &[".gitconfig", ".config/git/config"]; - -/// Deepest `include` chain followed when collecting git config files. -const GITCONFIG_MAX_DEPTH: usize = 8; - -/// Bound admission work even for attacker-controlled directory trees. -const SUBTREE_MAX_ENTRIES: usize = 1_000_000; -const SUBTREE_MAX_DEPTH: usize = 128; - -/// Paths the jail is granted beyond the workspace root, all canonical and -/// existing. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct JailGrants { - /// Canonical read and executable paths. - pub read_only: Vec, - /// Canonical writable paths. - pub read_write: Vec, -} - -/// Build the grant set for `home` (the user's home directory, if known). -#[must_use] -pub fn resolve_local_jail_grants( - home: Option<&Path>, - cfg: &LocalGrantConfig, - protected: &ProtectedPaths, -) -> JailGrants { - let home = home.map(|h| h.canonicalize().unwrap_or_else(|_| h.to_path_buf())); - let mut b = Builder::new(home.as_deref(), protected); - - if cfg.toolchain_homes { - 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"); - } - } - for dir in SYSTEM_TOOLCHAIN_DIRS { - b.fixed_read_only(Path::new(dir), "toolchain"); - } - if let Some(h) = home.as_deref() { - b.add_gitconfig(h); - } - } - for raw in &cfg.extra_read_only { - if let Some(p) = b.expand(raw) { - b.read_only(&p, "config"); - } - } - for raw in &cfg.extra_read_write { - if let Some(p) = b.expand(raw) { - b.read_write(&p, "config"); - } - } - // The one sanctioned route to `/proc`: it bypasses the floor deliberately. - if cfg.allow_proc && Path::new("/proc").exists() { - log::debug!("[sandbox:grants] granting read-only /proc (allow_proc = true)"); - b.grants.read_only.push(PathBuf::from("/proc")); - } - log::debug!( - "resolved {} read-only and {} writable local grants", - b.grants.read_only.len(), - b.grants.read_write.len() - ); - b.grants -} - -struct Builder<'a> { - home: Option<&'a Path>, - protected: &'a ProtectedPaths, - grants: JailGrants, - seen: HashSet, - fixed_read_only: Vec, -} - -impl<'a> Builder<'a> { - fn new(home: Option<&'a Path>, protected: &'a ProtectedPaths) -> Self { - Self { - home, - protected, - grants: JailGrants::default(), - seen: HashSet::new(), - fixed_read_only: Vec::new(), - } - } - - /// Expand a leading `~` / `~/` against the home directory. - fn expand(&self, raw: &str) -> Option { - let raw = raw.trim(); - if raw.is_empty() { - return None; - } - if raw == "~" { - return self.home.map(Path::to_path_buf); - } - if let Some(rest) = raw.strip_prefix("~/") { - return self.home.map(|h| h.join(rest)); - } - Some(PathBuf::from(raw)) - } - - /// Canonicalize `path` and return it only if it exists and clears the - /// credential floor on both its requested name and resolved target. - fn admit(&self, path: &Path, source: &str) -> Option { - if self.refuses_path(path) { - log::warn!( - "refused {source} grant reaching protected paths: {}", - path.display() - ); - return None; - } - let Ok(canonical) = path.canonicalize() else { - log::debug!("skipped missing {source} grant: {}", path.display()); - return None; - }; - if self.refuses_path(&canonical) { - log::warn!( - "refused {source} grant reaching protected paths: {}", - canonical.display() - ); - return None; - } - match self.subtree_is_clear(path, &canonical, SUBTREE_MAX_ENTRIES, SUBTREE_MAX_DEPTH) { - Ok(true) => Some(canonical), - Ok(false) => { - log::warn!( - "refused {source} grant containing protected entries: {}", - path.display() - ); - None - } - Err(error) => { - log::warn!( - "refused uninspectable {source} grant {}: {error}", - path.display() - ); - None - } - } - } - - /// Inspect recursive grants at discovery time. Never follow nested - /// symlinks: recursive kernel rules cover this directory tree, not targets - /// outside it. Check symlink names themselves before inspecting their type. - /// Carry the requested spelling too, so an aliased `.cargo` still protects - /// its `credentials.toml` child. Errors and traversal limits fail closed. - fn subtree_is_clear( - &self, - requested: &Path, - canonical: &Path, - max_entries: usize, - max_depth: usize, - ) -> std::io::Result { - if self.protected.components.is_empty() && self.protected.component_pairs.is_empty() { - return Ok(true); - } - if !std::fs::metadata(canonical)?.is_dir() { - return Ok(true); - } - let mut stack = vec![( - std::fs::read_dir(canonical)?, - canonical.to_path_buf(), - requested.to_path_buf(), - )]; - let mut visited = 0; - while let Some((entries, physical, logical)) = stack.last_mut() { - let Some(entry) = entries.next() else { - stack.pop(); - continue; - }; - let entry = entry?; - if visited == max_entries { - return Err(std::io::Error::other( - "protected-path inspection entry limit exceeded", - )); - } - visited += 1; - let physical_child = physical.join(entry.file_name()); - let logical_child = logical.join(entry.file_name()); - if self.has_protected_components(&physical_child) - || self.has_protected_components(&logical_child) - { - return Ok(false); - } - let file_type = entry.file_type()?; - // A pair-prefix symlink could alias a directory elsewhere inside - // this same grant, exposing its protected child under the alias. - // Reject it conservatively rather than following links or cycles. - if file_type.is_symlink() - && self.protected.component_pairs.iter().any(|pair| { - entry - .file_name() - .to_string_lossy() - .eq_ignore_ascii_case(&pair[0]) - }) - { - return Ok(false); - } - if file_type.is_dir() { - if stack.len() >= max_depth { - return Err(std::io::Error::other( - "protected-path inspection depth limit exceeded", - )); - } - stack.push(( - std::fs::read_dir(&physical_child)?, - physical_child, - logical_child, - )); - } - } - Ok(true) - } - - fn refuses_path(&self, path: &Path) -> bool { - let normalized = normalized_floor_path(&path.to_string_lossy()); - // Procfs is reserved for the dedicated read-only toggle, including - // ancestors whose recursive grant would expose it. - let exposes_proc = Path::new("/proc").exists() - && (same_or_descendant(&normalized, "/proc") - || same_or_descendant("/proc", &normalized)); - self.is_protected(path) || self.exposes_credentials(path) || exposes_proc - } - - /// True when `path` is, contains, or sits inside a credential directory. - fn exposes_credentials(&self, path: &Path) -> bool { - let path = normalized_floor_path(&path.to_string_lossy()); - self.protected.credential_roots.iter().any(|root| { - root_spellings(Path::new(root)) - .any(|cred| same_or_descendant(&path, &cred) || same_or_descendant(&cred, &path)) - }) - } - - fn has_protected_components(&self, path: &Path) -> bool { - let normalized = normalized_floor_path(&path.to_string_lossy()); - let components: Vec<_> = normalized - .split('/') - .filter(|part| !part.is_empty()) - .collect(); - components.iter().any(|part| { - self.protected - .components - .iter() - .any(|blocked| part.eq_ignore_ascii_case(blocked)) - }) || components.windows(2).any(|pair| { - self.protected.component_pairs.iter().any(|blocked| { - pair[0].eq_ignore_ascii_case(&blocked[0]) - && pair[1].eq_ignore_ascii_case(&blocked[1]) - }) - }) - } - - fn is_protected(&self, path: &Path) -> bool { - let normalized = normalized_floor_path(&path.to_string_lossy()); - self.has_protected_components(path) - || self.protected.prefixes.iter().any(|prefix| { - root_spellings(Path::new(prefix)).any(|prefix| { - same_or_descendant(&normalized, &prefix) - || same_or_descendant(&prefix, &normalized) - }) - }) - } - - fn read_only(&mut self, path: &Path, source: &str) { - self.add_read_only(path, source, false); - } - - fn fixed_read_only(&mut self, path: &Path, source: &str) { - self.add_read_only(path, source, true); - } - - 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); - } - } - } - - fn read_write(&mut self, path: &Path, source: &str) { - if let Some(p) = self.admit(path, source) { - let normalized = normalized_floor_path(&p.to_string_lossy()); - let reaches = |root: &Path| { - root_spellings(root).any(|root| { - same_or_descendant(&normalized, &root) || same_or_descendant(&root, &normalized) - }) - }; - let exposes_fixed = self.fixed_read_only.iter().any(|root| reaches(root)); - let exposes_cargo = self.home.is_some_and(|home| { - ["bin", "git", "registry"] - .into_iter() - .chain(CARGO_READ_ONLY_FILES.iter().copied()) - .any(|entry| reaches(&home.join(".cargo").join(entry))) - }); - if exposes_fixed || exposes_cargo { - log::warn!( - "refused writable grant reaching fixed host toolchain state: {}", - p.display() - ); - return; - } - // A path granted writable subsumes an earlier read-only grant. - self.grants.read_only.retain(|g| g != &p); - self.seen.insert(p.clone()); - if !self.grants.read_write.contains(&p) { - self.grants.read_write.push(p); - } - } - } - - /// Grant only the parts of `~/.cargo` that jailed Cargo needs. The root is - /// never writable: `bin` and Cargo configuration run later outside the - /// jail, so writable access there would persist an escape for host tools. - /// Shared dependency sources are also read-only: a cached build script can - /// execute during a later host build. Fetches need a disposable Cargo home. - fn add_cargo_home(&mut self, cargo: &Path) { - if !cargo.is_dir() { - return; - } - 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"); - for f in CARGO_READ_ONLY_FILES { - self.fixed_read_only(&cargo.join(f), "toolchain"); - } - } - - /// Read-only grants for the user's git config files and everything they - /// `include`, each canonicalized (a config is often a symlink into a - /// dotfiles repo, and the jail needs the target). - fn add_gitconfig(&mut self, home: &Path) { - let mut roots: Vec = GITCONFIG_FILES.iter().map(|f| home.join(f)).collect(); - if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { - roots.push(Path::new(&xdg).join("git/config")); - } - self.fixed_read_only(&home.join(".config/git/ignore"), "gitignore"); - if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { - self.fixed_read_only(&Path::new(&xdg).join("git/ignore"), "gitignore"); - } - let mut visited = HashSet::new(); - for root in roots { - self.add_gitconfig_file(&root, home, 0, &mut visited); - } - } - - fn add_gitconfig_file( - &mut self, - file: &Path, - home: &Path, - depth: usize, - visited: &mut HashSet, - ) { - let Some(canonical) = self.admit(file, "gitconfig") else { - return; - }; - if !canonical.is_file() || !visited.insert(canonical.clone()) { - return; - } - self.fixed_read_only(&canonical, "gitconfig"); - if depth >= GITCONFIG_MAX_DEPTH { - return; - } - let Ok(text) = std::fs::read_to_string(&canonical) else { - return; - }; - // Relative includes resolve against the directory of the file *as - // named*, which for a symlinked config is the link's directory. - let base = file.parent().unwrap_or(home); - for target in include_paths(&text) { - let resolved = if let Some(rest) = target.strip_prefix("~/") { - home.join(rest) - } else if Path::new(&target).is_absolute() { - PathBuf::from(&target) - } else { - base.join(&target) - }; - self.add_gitconfig_file(&resolved, home, depth + 1, visited); - } - } -} - -// A configured protected root may itself be a symlink (for example /etc on -// macOS). Preserve its requested spelling and its existing resolved target. -fn root_spellings(root: &Path) -> impl Iterator { - let location = root.parent().and_then(|parent| { - let mut ancestor = parent; - let mut missing = Vec::new(); - let mut canonical = loop { - if let Ok(canonical) = ancestor.canonicalize() { - break canonical; - } - missing.push(ancestor.file_name()?); - ancestor = ancestor.parent()?; - }; - for component in missing.into_iter().rev() { - canonical.push(component); - } - canonical.push(root.file_name()?); - Some(normalized_floor_path(&canonical.to_string_lossy())) - }); - std::iter::once(normalized_floor_path(&root.to_string_lossy())) - .chain(location) - .chain( - root.canonicalize() - .ok() - .map(|canonical| normalized_floor_path(&canonical.to_string_lossy())), - ) -} - -// Windows canonical paths may carry an extended drive or UNC namespace; -// compare the underlying filesystem path, regardless of spelling or case. -fn normalized_floor_path(path: &str) -> String { - let normalized = path.to_ascii_lowercase().replace('\\', "/"); - if let Some(rest) = normalized.strip_prefix("//?/unc/") { - format!("//{rest}") - } else { - normalized - .strip_prefix("//?/") - .unwrap_or(&normalized) - .to_owned() - } -} - -fn same_or_descendant(candidate: &str, parent: &str) -> bool { - let root = parent.trim_end_matches('/'); - if root.is_empty() { - return parent.starts_with('/') && candidate.starts_with('/'); - } - candidate == root - || candidate - .strip_prefix(root) - .is_some_and(|rest| rest.starts_with('/')) -} - -/// `path = …` values inside `[include]` / `[includeIf …]` sections. -fn include_paths(config: &str) -> Vec { - let mut in_include = false; - let mut out = Vec::new(); - for line in config.lines() { - let line = line.trim(); - if let Some(section) = line.strip_prefix('[') { - let name = section.split([']', ' ', '"']).next().unwrap_or(""); - in_include = - name.eq_ignore_ascii_case("include") || name.eq_ignore_ascii_case("includeif"); - continue; - } - if !in_include { - continue; - } - if let Some((key, value)) = line.split_once('=') - && key.trim().eq_ignore_ascii_case("path") - { - let value = value.trim().trim_matches('"'); - if !value.is_empty() { - out.push(value.to_string()); - } - } - } - out -} - -#[cfg(test)] -#[path = "grants_tests.rs"] -mod tests; diff --git a/crates/tinybox-jail/src/grants/floor.rs b/crates/tinybox-jail/src/grants/floor.rs new file mode 100644 index 00000000..3f68def0 --- /dev/null +++ b/crates/tinybox-jail/src/grants/floor.rs @@ -0,0 +1,107 @@ +//! Protected root spellings and component-boundary matching. +use super::Builder; +use std::path::Path; + +impl Builder<'_> { + pub(super) fn refuses_path(&self, path: &Path) -> bool { + let normalized = normalized_floor_path(&path.to_string_lossy()); + // Procfs is reserved for the dedicated read-only toggle, including + // ancestors whose recursive grant would expose it. + let exposes_proc = Path::new("/proc").exists() + && (same_or_descendant(&normalized, "/proc") + || same_or_descendant("/proc", &normalized)); + self.is_protected(path) || self.exposes_credentials(path) || exposes_proc + } + + /// True when `path` is, contains, or sits inside a credential directory. + fn exposes_credentials(&self, path: &Path) -> bool { + let path = normalized_floor_path(&path.to_string_lossy()); + self.protected.credential_roots.iter().any(|root| { + root_spellings(Path::new(root)) + .any(|cred| same_or_descendant(&path, &cred) || same_or_descendant(&cred, &path)) + }) + } + + pub(super) fn has_protected_components(&self, path: &Path) -> bool { + let normalized = normalized_floor_path(&path.to_string_lossy()); + let components: Vec<_> = normalized + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + components.iter().any(|part| { + self.protected + .components + .iter() + .any(|blocked| part.eq_ignore_ascii_case(blocked)) + }) || components.windows(2).any(|pair| { + self.protected.component_pairs.iter().any(|blocked| { + pair[0].eq_ignore_ascii_case(&blocked[0]) + && pair[1].eq_ignore_ascii_case(&blocked[1]) + }) + }) + } + + fn is_protected(&self, path: &Path) -> bool { + let normalized = normalized_floor_path(&path.to_string_lossy()); + self.has_protected_components(path) + || self.protected.prefixes.iter().any(|prefix| { + root_spellings(Path::new(prefix)).any(|prefix| { + same_or_descendant(&normalized, &prefix) + || same_or_descendant(&prefix, &normalized) + }) + }) + } +} + +// A configured protected root may itself be a symlink (for example /etc on +// macOS). Preserve its requested spelling and its existing resolved target. +pub(super) fn root_spellings(root: &Path) -> impl Iterator { + let location = root.parent().and_then(|parent| { + let mut ancestor = parent; + let mut missing = Vec::new(); + let mut canonical = loop { + if let Ok(canonical) = ancestor.canonicalize() { + break canonical; + } + missing.push(ancestor.file_name()?); + ancestor = ancestor.parent()?; + }; + for component in missing.into_iter().rev() { + canonical.push(component); + } + canonical.push(root.file_name()?); + Some(normalized_floor_path(&canonical.to_string_lossy())) + }); + std::iter::once(normalized_floor_path(&root.to_string_lossy())) + .chain(location) + .chain( + root.canonicalize() + .ok() + .map(|canonical| normalized_floor_path(&canonical.to_string_lossy())), + ) +} + +// Windows canonical paths may carry an extended drive or UNC namespace; +// compare the underlying filesystem path, regardless of spelling or case. +pub(super) fn normalized_floor_path(path: &str) -> String { + let normalized = path.to_ascii_lowercase().replace('\\', "/"); + if let Some(rest) = normalized.strip_prefix("//?/unc/") { + format!("//{rest}") + } else { + normalized + .strip_prefix("//?/") + .unwrap_or(&normalized) + .to_owned() + } +} + +pub(super) fn same_or_descendant(candidate: &str, parent: &str) -> bool { + let root = parent.trim_end_matches('/'); + if root.is_empty() { + return parent.starts_with('/') && candidate.starts_with('/'); + } + candidate == root + || candidate + .strip_prefix(root) + .is_some_and(|rest| rest.starts_with('/')) +} diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs new file mode 100644 index 00000000..481e1bfd --- /dev/null +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -0,0 +1,129 @@ +//! Bounded Git configuration include discovery. +use super::Builder; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// Git config files read at startup, relative to `$HOME`. +const GITCONFIG_FILES: &[&str] = &[".gitconfig", ".config/git/config"]; + +/// Deepest `include` chain followed when collecting git config files. +const GITCONFIG_MAX_DEPTH: usize = 8; + +impl Builder<'_> { + /// Read-only grants for the user's git config files and everything they + /// `include`, each canonicalized (a config is often a symlink into a + /// dotfiles repo, and the jail needs the target). + pub(super) fn add_gitconfig(&mut self, home: &Path) { + let mut roots: Vec = GITCONFIG_FILES.iter().map(|f| home.join(f)).collect(); + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + roots.push(Path::new(&xdg).join("git/config")); + } + self.fixed_read_only(&home.join(".config/git/ignore"), "gitignore"); + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + self.fixed_read_only(&Path::new(&xdg).join("git/ignore"), "gitignore"); + } + let mut visited = HashSet::new(); + for root in roots { + self.add_gitconfig_file(&root, home, 0, &mut visited); + } + } + + fn add_gitconfig_file( + &mut self, + file: &Path, + home: &Path, + depth: usize, + visited: &mut HashSet, + ) { + let Some(canonical) = self.admit(file, "gitconfig") else { + return; + }; + if !canonical.is_file() || !visited.insert(canonical.clone()) { + return; + } + self.fixed_read_only(&canonical, "gitconfig"); + if depth >= GITCONFIG_MAX_DEPTH { + return; + } + let Ok(text) = std::fs::read_to_string(&canonical) else { + return; + }; + // Relative includes resolve against the directory of the file *as + // named*, which for a symlinked config is the link's directory. + let base = file.parent().unwrap_or(home); + for target in include_paths(&text) { + let resolved = if let Some(rest) = target.strip_prefix("~/") { + home.join(rest) + } else if Path::new(&target).is_absolute() { + PathBuf::from(&target) + } else { + base.join(&target) + }; + self.add_gitconfig_file(&resolved, home, depth + 1, visited); + } + } +} + +/// `path = …` values inside `[include]` / `[includeIf …]` sections. +pub(super) fn include_paths(config: &str) -> Vec { + let mut in_include = false; + let mut out = Vec::new(); + for line in config.lines() { + let line = line.trim(); + if let Some(section) = line.strip_prefix('[') { + let name = section.split([']', ' ', '"']).next().unwrap_or(""); + in_include = + name.eq_ignore_ascii_case("include") || name.eq_ignore_ascii_case("includeif"); + continue; + } + if !in_include { + continue; + } + if let Some((key, value)) = line.split_once('=') + && key.trim().eq_ignore_ascii_case("path") + { + if let Some(value) = include_value(value) { + out.push(value); + } + } + } + out +} + +// Git comments begin only outside double quotes. Decode Git's supported +// escapes and reject malformed values instead of inventing filesystem paths. +fn include_value(value: &str) -> Option { + let mut chars = value.trim_start().chars(); + let mut quoted = false; + let mut out = String::new(); + let mut significant = 0; + while let Some(ch) = chars.next() { + match ch { + '"' => quoted = !quoted, + '#' | ';' if !quoted => break, + '\\' => { + let decoded = match chars.next()? { + 'n' => '\n', + 't' => '\t', + 'b' => '\u{0008}', + '\\' => '\\', + '"' => '"', + _ => return None, + }; + out.push(decoded); + significant = out.len(); + } + _ => { + out.push(ch); + if quoted || !ch.is_whitespace() { + significant = out.len(); + } + } + } + } + if quoted { + return None; + } + out.truncate(significant); + (!out.is_empty()).then_some(out) +} diff --git a/crates/tinybox-jail/src/grants/inspection.rs b/crates/tinybox-jail/src/grants/inspection.rs new file mode 100644 index 00000000..f08ae0c8 --- /dev/null +++ b/crates/tinybox-jail/src/grants/inspection.rs @@ -0,0 +1,78 @@ +//! Bounded, fail-closed discovery of protected descendant names. +use super::Builder; +use std::path::Path; + +impl Builder<'_> { + /// Inspect recursive grants at discovery time. Never follow nested + /// symlinks: recursive kernel rules cover this directory tree, not targets + /// outside it. Check symlink names themselves before inspecting their type. + /// Carry the requested spelling too, so an aliased `.cargo` still protects + /// its `credentials.toml` child. Errors and traversal limits fail closed. + pub(super) fn subtree_is_clear( + &self, + requested: &Path, + canonical: &Path, + max_entries: usize, + max_depth: usize, + ) -> std::io::Result { + if self.protected.components.is_empty() && self.protected.component_pairs.is_empty() { + return Ok(true); + } + if !std::fs::metadata(canonical)?.is_dir() { + return Ok(true); + } + let mut stack = vec![( + std::fs::read_dir(canonical)?, + canonical.to_path_buf(), + requested.to_path_buf(), + )]; + let mut visited = 0; + while let Some((entries, physical, logical)) = stack.last_mut() { + let Some(entry) = entries.next() else { + stack.pop(); + continue; + }; + let entry = entry?; + if visited == max_entries { + return Err(std::io::Error::other( + "protected-path inspection entry limit exceeded", + )); + } + visited += 1; + let physical_child = physical.join(entry.file_name()); + let logical_child = logical.join(entry.file_name()); + if self.has_protected_components(&physical_child) + || self.has_protected_components(&logical_child) + { + return Ok(false); + } + let file_type = entry.file_type()?; + // A pair-prefix symlink could alias a directory elsewhere inside + // this same grant, exposing its protected child under the alias. + // Reject it conservatively rather than following links or cycles. + if file_type.is_symlink() + && self.protected.component_pairs.iter().any(|pair| { + entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(&pair[0]) + }) + { + return Ok(false); + } + if file_type.is_dir() { + if stack.len() >= max_depth { + return Err(std::io::Error::other( + "protected-path inspection depth limit exceeded", + )); + } + stack.push(( + std::fs::read_dir(&physical_child)?, + physical_child, + logical_child, + )); + } + } + Ok(true) + } +} diff --git a/crates/tinybox-jail/src/grants/mod.rs b/crates/tinybox-jail/src/grants/mod.rs new file mode 100644 index 00000000..bc8c918c --- /dev/null +++ b/crates/tinybox-jail/src/grants/mod.rs @@ -0,0 +1,224 @@ +//! Discover canonical local toolchain grants while preserving a host-supplied protected-path floor. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use tinybox_bus::{LocalGrantConfig, ProtectedPaths}; + +/// System toolchain roots granted read-only when present. +const SYSTEM_TOOLCHAIN_DIRS: &[&str] = &["/usr/local", "/opt"]; + +/// `$HOME`-relative toolchain homes granted read-only when present. +const HOME_READ_ONLY_DIRS: &[&str] = &[".rustup", ".nvm", ".npm"]; + +/// Files Cargo needs read access to inside `~/.cargo` when the directory also +/// holds registry credentials and so cannot be granted whole. +const CARGO_READ_ONLY_FILES: &[&str] = &["config.toml", "config", "env"]; + +mod floor; +mod git_config; +mod inspection; +mod types; + +use floor::{normalized_floor_path, root_spellings, same_or_descendant}; +#[cfg(test)] +use git_config::include_paths; +pub use types::JailGrants; + +/// Bound admission work even for attacker-controlled directory trees. +const SUBTREE_MAX_ENTRIES: usize = 1_000_000; +const SUBTREE_MAX_DEPTH: usize = 128; + +/// Build the grant set for `home` (the user's home directory, if known). +#[must_use] +pub fn resolve_local_jail_grants( + home: Option<&Path>, + cfg: &LocalGrantConfig, + protected: &ProtectedPaths, +) -> JailGrants { + let home = home.map(|h| h.canonicalize().unwrap_or_else(|_| h.to_path_buf())); + let mut b = Builder::new(home.as_deref(), protected); + + if cfg.toolchain_homes { + 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"); + } + } + for dir in SYSTEM_TOOLCHAIN_DIRS { + b.fixed_read_only(Path::new(dir), "toolchain"); + } + if let Some(h) = home.as_deref() { + b.add_gitconfig(h); + } + } + for raw in &cfg.extra_read_only { + if let Some(p) = b.expand(raw) { + b.read_only(&p, "config"); + } + } + for raw in &cfg.extra_read_write { + if let Some(p) = b.expand(raw) { + b.read_write(&p, "config"); + } + } + // The one sanctioned route to `/proc`: it bypasses the floor deliberately. + if cfg.allow_proc && Path::new("/proc").exists() { + log::debug!("[sandbox:grants] granting read-only /proc (allow_proc = true)"); + b.grants.read_only.push(PathBuf::from("/proc")); + } + log::debug!( + "resolved {} read-only and {} writable local grants", + b.grants.read_only.len(), + b.grants.read_write.len() + ); + b.grants +} + +struct Builder<'a> { + home: Option<&'a Path>, + protected: &'a ProtectedPaths, + grants: JailGrants, + seen: HashSet, + fixed_read_only: Vec, +} + +impl<'a> Builder<'a> { + fn new(home: Option<&'a Path>, protected: &'a ProtectedPaths) -> Self { + Self { + home, + protected, + grants: JailGrants::default(), + seen: HashSet::new(), + fixed_read_only: Vec::new(), + } + } + + /// Expand a leading `~` / `~/` against the home directory. + fn expand(&self, raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + if raw == "~" { + return self.home.map(Path::to_path_buf); + } + if let Some(rest) = raw.strip_prefix("~/") { + return self.home.map(|h| h.join(rest)); + } + Some(PathBuf::from(raw)) + } + + /// Canonicalize `path` and return it only if it exists and clears the + /// credential floor on both its requested name and resolved target. + fn admit(&self, path: &Path, source: &str) -> Option { + if self.refuses_path(path) { + log::warn!( + "refused {source} grant reaching protected paths: {}", + path.display() + ); + return None; + } + let Ok(canonical) = path.canonicalize() else { + log::debug!("skipped missing {source} grant: {}", path.display()); + return None; + }; + if self.refuses_path(&canonical) { + log::warn!( + "refused {source} grant reaching protected paths: {}", + canonical.display() + ); + return None; + } + match self.subtree_is_clear(path, &canonical, SUBTREE_MAX_ENTRIES, SUBTREE_MAX_DEPTH) { + Ok(true) => Some(canonical), + Ok(false) => { + log::warn!( + "refused {source} grant containing protected entries: {}", + path.display() + ); + None + } + Err(error) => { + log::warn!( + "refused uninspectable {source} grant {}: {error}", + path.display() + ); + None + } + } + } + + fn read_only(&mut self, path: &Path, source: &str) { + self.add_read_only(path, source, false); + } + + fn fixed_read_only(&mut self, path: &Path, source: &str) { + self.add_read_only(path, source, true); + } + + 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); + } + } + } + + fn read_write(&mut self, path: &Path, source: &str) { + if let Some(p) = self.admit(path, source) { + let normalized = normalized_floor_path(&p.to_string_lossy()); + let reaches = |root: &Path| { + root_spellings(root).any(|root| { + same_or_descendant(&normalized, &root) || same_or_descendant(&root, &normalized) + }) + }; + let exposes_fixed = self.fixed_read_only.iter().any(|root| reaches(root)); + let exposes_cargo = self.home.is_some_and(|home| { + ["bin", "git", "registry"] + .into_iter() + .chain(CARGO_READ_ONLY_FILES.iter().copied()) + .any(|entry| reaches(&home.join(".cargo").join(entry))) + }); + if exposes_fixed || exposes_cargo { + log::warn!( + "refused writable grant reaching fixed host toolchain state: {}", + p.display() + ); + return; + } + // A path granted writable subsumes an earlier read-only grant. + self.grants.read_only.retain(|g| g != &p); + self.seen.insert(p.clone()); + if !self.grants.read_write.contains(&p) { + self.grants.read_write.push(p); + } + } + } + + /// Grant only the parts of `~/.cargo` that jailed Cargo needs. The root is + /// never writable: `bin` and Cargo configuration run later outside the + /// jail, so writable access there would persist an escape for host tools. + /// Shared dependency sources are also read-only: a cached build script can + /// execute during a later host build. Fetches need a disposable Cargo home. + fn add_cargo_home(&mut self, cargo: &Path) { + if !cargo.is_dir() { + return; + } + 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"); + for f in CARGO_READ_ONLY_FILES { + self.fixed_read_only(&cargo.join(f), "toolchain"); + } + } +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/grants_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs similarity index 98% rename from crates/tinybox-jail/src/grants_tests.rs rename to crates/tinybox-jail/src/grants/mod_tests.rs index ee1f63c8..f70be7cc 100644 --- a/crates/tinybox-jail/src/grants_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -956,13 +956,20 @@ fn subtree_pair_protection_cannot_be_bypassed_by_a_directory_symlink() #[test] fn git_includes_strip_unquoted_comments_but_preserve_quoted_markers() { assert_eq!( - include_paths(r#"[include] + include_paths( + r#"[include] path = ~/work.conf # local path = other.conf ; explanation path = "quoted#name;value.conf" # trailing path = prefix"quoted#part"suffix.conf ; trailing -"#), - ["~/work.conf", "other.conf", "quoted#name;value.conf", "prefixquoted#partsuffix.conf"] +"# + ), + [ + "~/work.conf", + "other.conf", + "quoted#name;value.conf", + "prefixquoted#partsuffix.conf" + ] ); } @@ -970,11 +977,19 @@ path = prefix"quoted#part"suffix.conf ; trailing fn git_include_with_a_trailing_comment_grants_the_actual_file() -> Result<(), Box> { let home = tempfile::tempdir()?; - fs::write(home.path().join(".gitconfig"), "[include]\npath = ~/work.conf # local\n")?; + fs::write( + home.path().join(".gitconfig"), + "[include]\npath = ~/work.conf # local\n", + )?; fs::write(home.path().join("work.conf"), "[core]\n")?; let grants = resolve_local_jail_grants( - Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default() + Some(home.path()), + &LocalGrantConfig::default(), + &ProtectedPaths::default(), ); - assert!(has(&grants.read_only, &canon(&home.path().join("work.conf"))?)); + assert!(has( + &grants.read_only, + &canon(&home.path().join("work.conf"))? + )); Ok(()) } diff --git a/crates/tinybox-jail/src/grants/types.rs b/crates/tinybox-jail/src/grants/types.rs new file mode 100644 index 00000000..b0cd0d03 --- /dev/null +++ b/crates/tinybox-jail/src/grants/types.rs @@ -0,0 +1,12 @@ +//! Canonical local grant results. +use std::path::PathBuf; + +/// Paths the jail is granted beyond the workspace root, all canonical and +/// existing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct JailGrants { + /// Canonical read and executable paths. + pub read_only: Vec, + /// Canonical writable paths. + pub read_write: Vec, +} From c91012739d33c1f66f73f3d78feb66127e0fdc72 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:21:42 +0300 Subject: [PATCH 24/38] chore: files changed crates/tinybox-jail/src/grants/floor.rs,crates/tinybox-jail/src/grants/git_conf Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/floor.rs | 4 ++-- crates/tinybox-jail/src/grants/git_config.rs | 4 ++++ .../src/grants/git_config_tests.rs | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 crates/tinybox-jail/src/grants/git_config_tests.rs diff --git a/crates/tinybox-jail/src/grants/floor.rs b/crates/tinybox-jail/src/grants/floor.rs index 3f68def0..5829fb8a 100644 --- a/crates/tinybox-jail/src/grants/floor.rs +++ b/crates/tinybox-jail/src/grants/floor.rs @@ -14,7 +14,7 @@ impl Builder<'_> { } /// True when `path` is, contains, or sits inside a credential directory. - fn exposes_credentials(&self, path: &Path) -> bool { + pub(super) fn exposes_credentials(&self, path: &Path) -> bool { let path = normalized_floor_path(&path.to_string_lossy()); self.protected.credential_roots.iter().any(|root| { root_spellings(Path::new(root)) @@ -41,7 +41,7 @@ impl Builder<'_> { }) } - fn is_protected(&self, path: &Path) -> bool { + pub(super) fn is_protected(&self, path: &Path) -> bool { let normalized = normalized_floor_path(&path.to_string_lossy()); self.has_protected_components(path) || self.protected.prefixes.iter().any(|prefix| { diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index 481e1bfd..592f5134 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -127,3 +127,7 @@ fn include_value(value: &str) -> Option { out.truncate(significant); (!out.is_empty()).then_some(out) } + +#[cfg(test)] +#[path = "git_config_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs new file mode 100644 index 00000000..487fb94a --- /dev/null +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn include_values_decode_git_escapes_and_preserve_quoted_whitespace() { + assert_eq!( + include_value(r#" " a\n\t\b\\\"#; " # comment"#), + Some(" a\n\t\u{0008}\\\"#; ".into()) + ); + assert_eq!(include_value(" safe ; comment"), Some("safe".into())); + assert_eq!(include_value(r#"a""b"#), Some("ab".into())); +} + +#[test] +fn malformed_or_empty_include_values_are_not_granted() { + for value in ["", " # comment", "; comment", "\"\"", "\"unterminated", "\\", r"\q"] { + assert_eq!(include_value(value), None, "{value}"); + } +} From bdc3f4431134b57b4eebdb4eae381a34d19eb49c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:22:11 +0300 Subject: [PATCH 25/38] test(grants): reformat malformed include value cases Reformat the list of malformed and empty include values in the git config test into a multi-line array so each case is on its own line. No behaviour change; the same values are still asserted to yield no grant. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config_tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 487fb94a..32076b0d 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -12,7 +12,15 @@ fn include_values_decode_git_escapes_and_preserve_quoted_whitespace() { #[test] fn malformed_or_empty_include_values_are_not_granted() { - for value in ["", " # comment", "; comment", "\"\"", "\"unterminated", "\\", r"\q"] { + for value in [ + "", + " # comment", + "; comment", + "\"\"", + "\"unterminated", + "\\", + r"\q", + ] { assert_eq!(include_value(value), None, "{value}"); } } From d8d23be928b3be647aed5b85ade676939a57c1df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:23:05 +0300 Subject: [PATCH 26/38] test(grants): use raw string with double hashes in git config test The test input contains a `"#` sequence, which terminated the single-hash raw string early and broke compilation. Switching to a double-hash raw string keeps the literal intact. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 32076b0d..9496b14f 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -3,7 +3,7 @@ use super::*; #[test] fn include_values_decode_git_escapes_and_preserve_quoted_whitespace() { assert_eq!( - include_value(r#" " a\n\t\b\\\"#; " # comment"#), + include_value(r##" " a\n\t\b\\\"#; " # comment"##), Some(" a\n\t\u{0008}\\\"#; ".into()) ); assert_eq!(include_value(" safe ; comment"), Some("safe".into())); From 8be2a7ef05ff0773bb38414f76b43a0af6c65e1d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:23:29 +0300 Subject: [PATCH 27/38] refactor(tinybox-jail): collapse nested if in git config include parsing Flatten the nested conditional that extracts include paths from git config into a single let-chain, so the value is pushed directly once both the key and value checks pass. No behaviour change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index 592f5134..2b2dadfb 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -81,10 +81,9 @@ pub(super) fn include_paths(config: &str) -> Vec { } if let Some((key, value)) = line.split_once('=') && key.trim().eq_ignore_ascii_case("path") + && let Some(value) = include_value(value) { - if let Some(value) = include_value(value) { - out.push(value); - } + out.push(value); } } out From 9a8e2961f6378e5b00f5051b90386180eded692d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:29:41 +0300 Subject: [PATCH 28/38] test(grants): cover symlinked git configs under writable parents Add two unix-only tests asserting that discovered git config symlinks stay read-only when their parent directories are granted read-write, and that duplicate targets reached through separate aliases each remain read-only. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/mod_tests.rs | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index f70be7cc..cfd2d9f4 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -993,3 +993,42 @@ fn git_include_with_a_trailing_comment_grants_the_actual_file() )); Ok(()) } + +#[test] +#[cfg(unix)] +fn discovered_git_config_links_cannot_be_replaced_through_writable_parents() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join(".config/git"))?; + fs::create_dir(home.path().join("dotfiles"))?; + let target = home.path().join("dotfiles/gitconfig"); + fs::write(&target, "[core]\n")?; + std::os::unix::fs::symlink(&target, home.path().join(".config/git/config"))?; + let config = LocalGrantConfig { + extra_read_write: vec!["~/.config".into(), "~/.config/git".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn duplicate_git_config_targets_keep_each_discovered_alias_read_only() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir_all(home.path().join(".config/git"))?; + fs::create_dir(home.path().join("dotfiles"))?; + let target = home.path().join("dotfiles/gitconfig"); + fs::write(&target, "[core]\n")?; + std::os::unix::fs::symlink(&target, home.path().join(".gitconfig"))?; + std::os::unix::fs::symlink(&target, home.path().join(".config/git/config"))?; + let config = LocalGrantConfig { + extra_read_write: vec!["~/.config/git".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} From e5acb1a8d1e978afb6075803d852aa0de888c7f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:32:17 +0300 Subject: [PATCH 29/38] fix(git_config): protect each requested gitconfig link location The read-only grant was applied to the canonical target and skipped entirely when a config had already been visited, so a second request for the same target left its own link location writable and replaceable. Each requested path is now protected before deduplication, while the visited check still guards recursion. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config.rs | 8 +++++--- crates/tinybox-jail/src/grants/git_config_tests.rs | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index 2b2dadfb..6e53ff10 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -38,11 +38,13 @@ impl Builder<'_> { let Some(canonical) = self.admit(file, "gitconfig") else { return; }; - if !canonical.is_file() || !visited.insert(canonical.clone()) { + if !canonical.is_file() { return; } - self.fixed_read_only(&canonical, "gitconfig"); - if depth >= GITCONFIG_MAX_DEPTH { + // Protect each requested link location, even when multiple configs + // resolve to one target. A writable parent could replace that link. + self.fixed_read_only(file, "gitconfig"); + if !visited.insert(canonical.clone()) || depth >= GITCONFIG_MAX_DEPTH { return; } let Ok(text) = std::fs::read_to_string(&canonical) else { diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 9496b14f..284f110d 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -1,3 +1,4 @@ +//! Git include decoding and malformed-value rejection coverage. use super::*; #[test] From af64d94b3ba811b7c2dc547ea4bb8f5119e46588 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:09:34 +0300 Subject: [PATCH 30/38] fix(jail): treat empty xdg_config_home as unset for git config An empty XDG_CONFIG_HOME was previously used as a path, which resolved to a relative git/config and git/ignore and could grant ambient configuration. The value is now ignored when empty, and git include aliases that resolve through symlinks keep each logical sibling location so shared includes are granted per directory. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config.rs | 12 +++++-- .../src/grants/git_config_tests.rs | 10 ++++++ crates/tinybox-jail/src/grants/mod_tests.rs | 33 +++++++++++++++++++ .../src/tinybus_module/mod_tests.rs | 20 +++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index 6e53ff10..610fbf25 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -14,12 +14,16 @@ impl Builder<'_> { /// `include`, each canonicalized (a config is often a symlink into a /// dotfiles repo, and the jail needs the target). pub(super) fn add_gitconfig(&mut self, home: &Path) { + self.add_gitconfig_at(home, std::env::var_os("XDG_CONFIG_HOME").as_deref()); + } + + pub(super) fn add_gitconfig_at(&mut self, home: &Path, xdg: Option<&std::ffi::OsStr>) { let mut roots: Vec = GITCONFIG_FILES.iter().map(|f| home.join(f)).collect(); - if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + if let Some(xdg) = xdg_root(xdg) { roots.push(Path::new(&xdg).join("git/config")); } self.fixed_read_only(&home.join(".config/git/ignore"), "gitignore"); - if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + if let Some(xdg) = xdg_root(xdg) { self.fixed_read_only(&Path::new(&xdg).join("git/ignore"), "gitignore"); } let mut visited = HashSet::new(); @@ -132,3 +136,7 @@ fn include_value(value: &str) -> Option { #[cfg(test)] #[path = "git_config_tests.rs"] mod tests; + +fn xdg_root(value: Option<&std::ffi::OsStr>) -> Option<&Path> { + value.map(Path::new) +} diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 284f110d..07917017 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -25,3 +25,13 @@ fn malformed_or_empty_include_values_are_not_granted() { assert_eq!(include_value(value), None, "{value}"); } } + +#[test] +fn empty_xdg_config_home_is_unset_in_git_root_selection() { + assert_eq!(xdg_root(Some(std::ffi::OsStr::new(""))), None); + assert_eq!(xdg_root(None), None); + assert_eq!( + xdg_root(Some(std::ffi::OsStr::new("config-root"))), + Some(Path::new("config-root")) + ); +} diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index cfd2d9f4..a9056240 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -1032,3 +1032,36 @@ fn duplicate_git_config_targets_keep_each_discovered_alias_read_only() assert_eq!(grants.read_write, Vec::::new()); Ok(()) } + +#[test] +#[cfg(unix)] +fn git_include_aliases_preserve_each_logical_sibling_location() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::write(home.path().join(".gitconfig"), "[include]\npath = one/alias\npath = two/alias\n")?; + fs::write(home.path().join("shared"), "[include]\npath = sibling\n")?; + for directory in ["one", "two"] { + fs::create_dir(home.path().join(directory))?; + std::os::unix::fs::symlink(home.path().join("shared"), home.path().join(directory).join("alias"))?; + fs::write(home.path().join(directory).join("sibling"), "[core]\n")?; + } + let grants = resolve_local_jail_grants(Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default()); + for directory in ["one", "two"] { + assert!(has(&grants.read_only, &canon(&home.path().join(directory).join("sibling"))?), "{directory}"); + } + Ok(()) +} + +#[test] +fn empty_xdg_config_home_does_not_grant_relative_git_configuration() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + let protected = ProtectedPaths::default(); + let mut builder = Builder::new(Some(home.path()), &protected); + builder.add_gitconfig_at(home.path(), Some(std::ffi::OsStr::new(""))); + // No HOME config exists. In particular, the empty value must not make + // ambient git/config or git/ignore into candidate protected state. + assert_eq!(builder.fixed_read_only, Vec::::new()); + assert_eq!(builder.grants, JailGrants::default()); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index b8558914..6ae91ba8 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1012,3 +1012,23 @@ async fn local_grant_discovery_accepts_exactly_the_final_limit() assert_eq!(result.read_write, Vec::::new()); Ok(()) } + +#[tokio::test] +async fn unsupported_git_include_expansions_refuse_the_whole_discovery() +-> Result<(), Box> { + for include in ["~other/shared.conf", "%(prefix)/shared.conf"] { + let home = tempfile::tempdir()?; + std::fs::write(home.path().join(".gitconfig"), format!("[include]\npath = {include}\n"))?; + let result = BoxService::default() + .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("non-UTF8 fixture")?.into()), + config: tinybox_bus::LocalGrantConfig::default(), + protected: tinybox_bus::ProtectedPaths::default(), + }).await; + assert!( + matches!(result, Err(tinybus::Error::MethodFailed { ref name, .. }) if name.as_str() == tinybox_bus::UNSUPPORTED_OPERATION), + "{include}: {result:?}" + ); + } + Ok(()) +} From e90167c745c6d7d0c2ebcd6bb22df430708865bd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:10:42 +0300 Subject: [PATCH 31/38] chore: files changed crates/tinybox-core/src/error/mod.rs,crates/tinybox-jail/src/grants/git_config. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-core/src/error/mod.rs | 4 + crates/tinybox-jail/src/grants/git_config.rs | 37 ++++-- crates/tinybox-jail/src/grants/mod.rs | 10 +- crates/tinybox-jail/src/grants/mod_tests.rs | 122 +++++++++++------- .../tinybox-module/src/tinybus_module/mod.rs | 6 + .../src/tinybus_module/mod_tests.rs | 8 +- 6 files changed, 117 insertions(+), 70 deletions(-) diff --git a/crates/tinybox-core/src/error/mod.rs b/crates/tinybox-core/src/error/mod.rs index 2afc88ba..fea14b24 100644 --- a/crates/tinybox-core/src/error/mod.rs +++ b/crates/tinybox-core/src/error/mod.rs @@ -15,6 +15,10 @@ use crate::capability::Capability; #[derive(Debug, thiserror::Error, PartialEq, Eq)] #[non_exhaustive] pub enum Error { + /// Local grant discovery cannot resolve a Git user-home or installation-prefix include. + #[error("unsupported git include pathname expansion")] + UnsupportedGitIncludePath, + /// An identifier was empty, over-long, or contained characters outside the /// permitted `[A-Za-z0-9._-]` set. #[error("identifier {value:?} is not a valid {kind}")] diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index 610fbf25..80c2a3e9 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -13,11 +13,15 @@ impl Builder<'_> { /// Read-only grants for the user's git config files and everything they /// `include`, each canonicalized (a config is often a symlink into a /// dotfiles repo, and the jail needs the target). - pub(super) fn add_gitconfig(&mut self, home: &Path) { - self.add_gitconfig_at(home, std::env::var_os("XDG_CONFIG_HOME").as_deref()); + pub(super) fn add_gitconfig(&mut self, home: &Path) -> tinybox_core::Result<()> { + self.add_gitconfig_at(home, std::env::var_os("XDG_CONFIG_HOME").as_deref()) } - pub(super) fn add_gitconfig_at(&mut self, home: &Path, xdg: Option<&std::ffi::OsStr>) { + pub(super) fn add_gitconfig_at( + &mut self, + home: &Path, + xdg: Option<&std::ffi::OsStr>, + ) -> tinybox_core::Result<()> { let mut roots: Vec = GITCONFIG_FILES.iter().map(|f| home.join(f)).collect(); if let Some(xdg) = xdg_root(xdg) { roots.push(Path::new(&xdg).join("git/config")); @@ -28,8 +32,9 @@ impl Builder<'_> { } let mut visited = HashSet::new(); for root in roots { - self.add_gitconfig_file(&root, home, 0, &mut visited); + self.add_gitconfig_file(&root, home, 0, &mut visited)?; } + Ok(()) } fn add_gitconfig_file( @@ -37,36 +42,40 @@ impl Builder<'_> { file: &Path, home: &Path, depth: usize, - visited: &mut HashSet, - ) { + visited: &mut HashSet<(PathBuf, PathBuf)>, + ) -> tinybox_core::Result<()> { let Some(canonical) = self.admit(file, "gitconfig") else { - return; + return Ok(()); }; if !canonical.is_file() { - return; + return Ok(()); } // Protect each requested link location, even when multiple configs // resolve to one target. A writable parent could replace that link. self.fixed_read_only(file, "gitconfig"); - if !visited.insert(canonical.clone()) || depth >= GITCONFIG_MAX_DEPTH { - return; + let base = file.parent().unwrap_or(home); + let location = base.canonicalize().unwrap_or_else(|_| base.to_path_buf()); + if !visited.insert((canonical.clone(), location)) || depth >= GITCONFIG_MAX_DEPTH { + return Ok(()); } let Ok(text) = std::fs::read_to_string(&canonical) else { - return; + return Ok(()); }; // Relative includes resolve against the directory of the file *as // named*, which for a symlinked config is the link's directory. - let base = file.parent().unwrap_or(home); for target in include_paths(&text) { let resolved = if let Some(rest) = target.strip_prefix("~/") { home.join(rest) + } else if target.starts_with('~') || target.starts_with("%(prefix)") { + return Err(tinybox_core::Error::UnsupportedGitIncludePath); } else if Path::new(&target).is_absolute() { PathBuf::from(&target) } else { base.join(&target) }; - self.add_gitconfig_file(&resolved, home, depth + 1, visited); + self.add_gitconfig_file(&resolved, home, depth + 1, visited)?; } + Ok(()) } } @@ -138,5 +147,5 @@ fn include_value(value: &str) -> Option { mod tests; fn xdg_root(value: Option<&std::ffi::OsStr>) -> Option<&Path> { - value.map(Path::new) + value.filter(|value| !value.is_empty()).map(Path::new) } diff --git a/crates/tinybox-jail/src/grants/mod.rs b/crates/tinybox-jail/src/grants/mod.rs index bc8c918c..b0c1c53e 100644 --- a/crates/tinybox-jail/src/grants/mod.rs +++ b/crates/tinybox-jail/src/grants/mod.rs @@ -29,12 +29,14 @@ const SUBTREE_MAX_ENTRIES: usize = 1_000_000; const SUBTREE_MAX_DEPTH: usize = 128; /// Build the grant set for `home` (the user's home directory, if known). -#[must_use] +/// # Errors +/// Returns an error for unsupported Git user-home or installation-prefix +/// includes instead of returning a partially discovered grant set. pub fn resolve_local_jail_grants( home: Option<&Path>, cfg: &LocalGrantConfig, protected: &ProtectedPaths, -) -> JailGrants { +) -> tinybox_core::Result { let home = home.map(|h| h.canonicalize().unwrap_or_else(|_| h.to_path_buf())); let mut b = Builder::new(home.as_deref(), protected); @@ -49,7 +51,7 @@ pub fn resolve_local_jail_grants( b.fixed_read_only(Path::new(dir), "toolchain"); } if let Some(h) = home.as_deref() { - b.add_gitconfig(h); + b.add_gitconfig(h)?; } } for raw in &cfg.extra_read_only { @@ -72,7 +74,7 @@ pub fn resolve_local_jail_grants( b.grants.read_only.len(), b.grants.read_write.len() ); - b.grants + Ok(b.grants) } struct Builder<'a> { diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index a9056240..6c4a9b54 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -42,7 +42,7 @@ fn reaches_credentials(grants: &[PathBuf], home: &Path) -> Option { #[test] fn credential_dirs_are_never_granted_by_default() -> Result<(), Box> { let home = fake_home()?; - let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let g = resolve(Some(home.path()), &LocalGrantConfig::default())?; assert_eq!(reaches_credentials(&all(&g), home.path()), None); Ok(()) } @@ -63,7 +63,7 @@ fn credential_dirs_are_never_granted_even_when_extras_ask() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let home = tempfile::tempdir()?; fs::create_dir_all(home.path().join(".nvm"))?; - let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let g = resolve(Some(home.path()), &LocalGrantConfig::default())?; let h = canon(home.path())?; assert!(has(&g.read_only, &h.join(".nvm"))); assert!(!all(&g).iter().any(|p| p.starts_with(h.join(".cargo")))); @@ -140,7 +140,7 @@ fn toolchain_homes_can_be_switched_off() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { let home = fake_home()?; - let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let g = resolve(Some(home.path()), &LocalGrantConfig::default())?; assert!(!all(&g).iter().any(|p| p.starts_with("/proc"))); // An `extra_*` entry cannot smuggle it in: only `allow_proc` does. @@ -201,14 +201,14 @@ fn proc_is_off_by_default_and_only_the_toggle_grants_it() -> Result<(), Box Result<(), Box Result<(), Box let home = fake_home()?; fs::write(home.path().join(".gitconfig"), "[include]\n path = ~/b\n")?; fs::write(home.path().join("b"), "[include]\n path = ~/.gitconfig\n")?; - let g = resolve(Some(home.path()), &LocalGrantConfig::default()); + let g = resolve(Some(home.path()), &LocalGrantConfig::default())?; assert!(has(&g.read_only, &canon(&home.path().join("b"))?)); Ok(()) } #[test] -fn no_home_yields_only_system_toolchain_dirs() { - let g = resolve(None, &LocalGrantConfig::default()); +fn no_home_yields_only_system_toolchain_dirs() -> Result<(), Box> { + let g = resolve(None, &LocalGrantConfig::default())?; assert_eq!(g.read_write, Vec::::new()); assert!( g.read_only .iter() .all(|p| p.starts_with("/usr/local") || p.starts_with("/opt")) ); + Ok(()) } fn floor(home: Option<&Path>) -> ProtectedPaths { @@ -323,7 +324,7 @@ fn floor(home: Option<&Path>) -> ProtectedPaths { .unwrap_or_default(), } } -fn resolve(home: Option<&Path>, config: &LocalGrantConfig) -> JailGrants { +fn resolve(home: Option<&Path>, config: &LocalGrantConfig) -> tinybox_core::Result { resolve_local_jail_grants(home, config, &floor(home)) } @@ -342,7 +343,7 @@ fn custom_host_floor_excludes_direct_and_ancestor_grants() -> Result<(), Box::new()); Ok(()) } @@ -358,20 +359,21 @@ fn host_floor_matches_component_pairs_and_case_insensitive_components() extra_read_only: vec!["~/.SSH".into(), "~/.cargo/credentials.toml".into()], ..LocalGrantConfig::default() }; - let grants = resolve(Some(home.path()), &config); + let grants = resolve(Some(home.path()), &config)?; assert_eq!(grants.read_only, Vec::::new()); Ok(()) } #[test] -fn extras_without_home_and_empty_values_are_skipped() { +fn extras_without_home_and_empty_values_are_skipped() -> Result<(), Box> { let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec![" ".into(), "~".into(), "~/unknown".into()], ..LocalGrantConfig::default() }; - let grants = resolve(None, &config); + let grants = resolve(None, &config)?; assert_eq!(grants.read_only, Vec::::new()); + Ok(()) } #[test] @@ -414,7 +416,7 @@ fn gitconfig_include_depth_is_bounded_and_non_files_are_skipped() format!("[include]\n path = ~/next-{}\n", index + 1), )?; } - let grants = resolve(Some(home.path()), &LocalGrantConfig::default()); + let grants = resolve(Some(home.path()), &LocalGrantConfig::default())?; assert!(has(&grants.read_only, &canon(&home.path().join("next-7"))?)); assert!(!has( &grants.read_only, @@ -495,37 +497,41 @@ fn protected_credential_alias_does_not_grant_its_neutral_target() components: vec![".npmrc".into()], ..ProtectedPaths::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected)?; assert_eq!(grants.read_only, Vec::::new()); Ok(()) } #[test] #[cfg(target_os = "linux")] -fn procfs_extras_cannot_bypass_the_dedicated_toggle_with_an_empty_floor() { +fn procfs_extras_cannot_bypass_the_dedicated_toggle_with_an_empty_floor() +-> Result<(), Box> { let config = LocalGrantConfig { toolchain_homes: false, extra_read_only: vec!["/proc".into(), "/proc/self".into()], extra_read_write: vec!["/proc".into(), "/proc/self".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(None, &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(None, &config, &ProtectedPaths::default())?; assert_eq!(grants.read_only, Vec::::new()); assert_eq!(grants.read_write, Vec::::new()); + Ok(()) } #[test] #[cfg(target_os = "linux")] -fn procfs_toggle_grants_read_only_access_even_when_extras_ask_for_writes() { +fn procfs_toggle_grants_read_only_access_even_when_extras_ask_for_writes() +-> Result<(), Box> { let config = LocalGrantConfig { toolchain_homes: false, allow_proc: true, extra_read_write: vec!["/proc".into(), "/proc/self".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(None, &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(None, &config, &ProtectedPaths::default())?; assert_eq!(grants.read_only, vec![PathBuf::from("/proc")]); assert_eq!(grants.read_write, Vec::::new()); + Ok(()) } #[test] @@ -541,7 +547,7 @@ fn discovered_cargo_dependency_sources_cannot_be_poisoned_for_later_host_builds( Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default(), - ); + )?; for cache in ["git", "registry"] { let source = home.path().join(".cargo").join(cache).canonicalize()?; assert!(has(&grants.read_only, &source), "{grants:?}"); @@ -569,7 +575,7 @@ fn rejected_git_config_cannot_grant_its_nested_includes() -> Result<(), Box::new()); Ok(()) } @@ -628,7 +634,7 @@ fn protected_prefix_alias_also_blocks_its_canonical_spelling() extra_read_only: vec![target.to_str().ok_or("non-UTF8 fixture")?.into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(None, &config, &protected); + let grants = resolve_local_jail_grants(None, &config, &protected)?; assert_eq!(grants.read_only, Vec::::new()); Ok(()) } @@ -645,7 +651,7 @@ fn explicit_writable_grants_cannot_promote_shared_cargo_sources() extra_read_write: vec!["~/.cargo/git".into(), "~/.cargo/registry".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); for cache in ["git", "registry"] { assert!( @@ -673,7 +679,7 @@ fn git_ignore_patterns_cannot_add_config_include_grants() -> Result<(), Box::new()); Ok(()) } @@ -747,7 +753,7 @@ fn credential_floor_preserves_alias_locations_under_aliased_parent_directories() extra_read_only: vec!["~".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(&home), &config, &protected); + let grants = resolve_local_jail_grants(Some(&home), &config, &protected)?; assert_eq!(grants.read_only, Vec::::new(), "{credential}"); } Ok(()) @@ -768,7 +774,7 @@ fn writable_extras_cannot_replace_cargo_executables_or_launch_configuration() .collect(), ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); Ok(()) } @@ -787,7 +793,7 @@ fn automatic_toolchain_roots_cannot_be_promoted_through_writable_descendants() .collect(), ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); Ok(()) } @@ -805,7 +811,7 @@ fn discovered_git_configuration_cannot_be_poisoned_via_its_target_ancestor() extra_read_write: vec!["~/dotfiles".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); Ok(()) } @@ -826,7 +832,7 @@ fn directory_extras_cannot_expose_nested_protected_components() extra_read_write: vec!["~/project".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &protected)?; assert_eq!(grants, JailGrants::default()); Ok(()) } @@ -850,7 +856,7 @@ fn directory_extras_cannot_expose_nested_protected_component_pairs() ..LocalGrantConfig::default() }; assert_eq!( - resolve_local_jail_grants(Some(home.path()), &config, &protected), + resolve_local_jail_grants(Some(home.path()), &config, &protected)?, JailGrants::default() ); Ok(()) @@ -874,7 +880,7 @@ fn aliased_directory_extras_preserve_logical_parent_component_pairs() ..LocalGrantConfig::default() }; assert_eq!( - resolve_local_jail_grants(Some(home.path()), &config, &protected), + resolve_local_jail_grants(Some(home.path()), &config, &protected)?, JailGrants::default() ); Ok(()) @@ -947,7 +953,7 @@ fn subtree_pair_protection_cannot_be_bypassed_by_a_directory_symlink() ..LocalGrantConfig::default() }; assert_eq!( - resolve_local_jail_grants(None, &config, &protected), + resolve_local_jail_grants(None, &config, &protected)?, JailGrants::default() ); Ok(()) @@ -986,7 +992,7 @@ fn git_include_with_a_trailing_comment_grants_the_actual_file() Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default(), - ); + )?; assert!(has( &grants.read_only, &canon(&home.path().join("work.conf"))? @@ -1008,7 +1014,7 @@ fn discovered_git_config_links_cannot_be_replaced_through_writable_parents() extra_read_write: vec!["~/.config".into(), "~/.config/git".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); Ok(()) } @@ -1028,7 +1034,7 @@ fn duplicate_git_config_targets_keep_each_discovered_alias_read_only() extra_read_write: vec!["~/.config/git".into()], ..LocalGrantConfig::default() }; - let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default()); + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); Ok(()) } @@ -1038,16 +1044,32 @@ fn duplicate_git_config_targets_keep_each_discovered_alias_read_only() fn git_include_aliases_preserve_each_logical_sibling_location() -> Result<(), Box> { let home = tempfile::tempdir()?; - fs::write(home.path().join(".gitconfig"), "[include]\npath = one/alias\npath = two/alias\n")?; + fs::write( + home.path().join(".gitconfig"), + "[include]\npath = one/alias\npath = two/alias\n", + )?; fs::write(home.path().join("shared"), "[include]\npath = sibling\n")?; for directory in ["one", "two"] { fs::create_dir(home.path().join(directory))?; - std::os::unix::fs::symlink(home.path().join("shared"), home.path().join(directory).join("alias"))?; + std::os::unix::fs::symlink( + home.path().join("shared"), + home.path().join(directory).join("alias"), + )?; fs::write(home.path().join(directory).join("sibling"), "[core]\n")?; } - let grants = resolve_local_jail_grants(Some(home.path()), &LocalGrantConfig::default(), &ProtectedPaths::default()); + let grants = resolve_local_jail_grants( + Some(home.path()), + &LocalGrantConfig::default(), + &ProtectedPaths::default(), + )?; for directory in ["one", "two"] { - assert!(has(&grants.read_only, &canon(&home.path().join(directory).join("sibling"))?), "{directory}"); + assert!( + has( + &grants.read_only, + &canon(&home.path().join(directory).join("sibling"))? + ), + "{directory}" + ); } Ok(()) } @@ -1058,7 +1080,7 @@ fn empty_xdg_config_home_does_not_grant_relative_git_configuration() let home = tempfile::tempdir()?; let protected = ProtectedPaths::default(); let mut builder = Builder::new(Some(home.path()), &protected); - builder.add_gitconfig_at(home.path(), Some(std::ffi::OsStr::new(""))); + builder.add_gitconfig_at(home.path(), Some(std::ffi::OsStr::new("")))?; // No HOME config exists. In particular, the empty value must not make // ambient git/config or git/ignore into candidate protected state. assert_eq!(builder.fixed_read_only, Vec::::new()); diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 05fe1737..623964a0 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -82,6 +82,12 @@ impl BoxService { .map_err(|_| { resources::failure(tinybox_bus::UNSUPPORTED_OPERATION, "grant discovery failed") })?; + let grants = grants.map_err(|_| { + resources::failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "unsupported git include pathname expansion", + ) + })?; if grants.read_only.len() + grants.read_write.len() > tinybox_bus::MAX_NATIVE_JAIL_GRANTS { return Err(resources::failure( tinybox_bus::RESOURCE_LIMIT, diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 6ae91ba8..c4aa9e00 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1018,13 +1018,17 @@ async fn unsupported_git_include_expansions_refuse_the_whole_discovery() -> Result<(), Box> { for include in ["~other/shared.conf", "%(prefix)/shared.conf"] { let home = tempfile::tempdir()?; - std::fs::write(home.path().join(".gitconfig"), format!("[include]\npath = {include}\n"))?; + std::fs::write( + home.path().join(".gitconfig"), + format!("[include]\npath = {include}\n"), + )?; let result = BoxService::default() .resolve_local_grants(tinybox_bus::ResolveLocalGrantsRequest { home: Some(home.path().to_str().ok_or("non-UTF8 fixture")?.into()), config: tinybox_bus::LocalGrantConfig::default(), protected: tinybox_bus::ProtectedPaths::default(), - }).await; + }) + .await; assert!( matches!(result, Err(tinybus::Error::MethodFailed { ref name, .. }) if name.as_str() == tinybox_bus::UNSUPPORTED_OPERATION), "{include}: {result:?}" From bcd846c1a43f64b7740ccefcc02a275314c8702d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:13:08 +0300 Subject: [PATCH 32/38] docs: document local grant discovery contract Add a spec and plan for local grant discovery, link them from the docs indexes, and describe the ResolveLocalGrants method in the module README. The e2e test now covers rejection of unsupported ~user/ and %(prefix)/ include expansions. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/README.md | 32 +++++++++++++++ .../src/tinybus_module/README.md | 5 +++ .../tests/grants_manifest_e2e.rs | 20 ++++++++++ docs/plans/README.md | 2 + docs/plans/local-grants.md | 27 +++++++++++++ docs/specs/README.md | 2 + docs/specs/local-grants.md | 40 +++++++++++++++++++ 7 files changed, 128 insertions(+) create mode 100644 crates/tinybox-jail/src/grants/README.md create mode 100644 docs/plans/local-grants.md create mode 100644 docs/specs/local-grants.md diff --git a/crates/tinybox-jail/src/grants/README.md b/crates/tinybox-jail/src/grants/README.md new file mode 100644 index 00000000..d3e638cc --- /dev/null +++ b/crates/tinybox-jail/src/grants/README.md @@ -0,0 +1,32 @@ +# Local grant discovery + +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. + +mod.rs orchestrates discovery and read-only precedence; types.rs holds results. +floor.rs matches components, adjacent pairs, protected prefixes and credential +roots, preserving requested and canonical aliases and ancestor boundaries. +inspection.rs scans directory descendants without following links. Errors, +over 1,000,000 entries or over 128 levels refuse a grant. Pair-prefix symlinks +are refused conservatively. Discovery is a snapshot, not a kernel filename +deny rule for entries created later; hosts must control tree mutation. + +git_config.rs reads admitted Git configs only. Relative includes retain their +logical parent, and visited identity combines canonical file and resolved parent. +Each link location remains read-only even when targets repeat. Includes stop at +depth eight. Quotes, supported Git escapes and unquoted comments are parsed. +Empty XDG_CONFIG_HOME is treated as unset. User-name and installation-prefix +include expansion cannot be resolved portably from the supplied contract: +~user/ and %(prefix)/ therefore reject the whole request explicitly, without +returning a partial grant set. + +Automatic toolchains, Cargo executable/configuration state, shared dependency +caches and Git configuration are fixed read-only. Writable extras cannot +promote them or their ancestors/descendants. Manual grants retain writable +precedence when they do not reach fixed state. Only allow_proc grants /proc, +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). diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index f612810c..0e7071ac 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -41,6 +41,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | WriteFileChunk | WriteFileChunkRequest | FileWriteProgress | | FinishFileWrite | FinishFileWriteRequest | FileWriteProgress | | AbortFileWrite | AbortFileWriteRequest | unit | +| ResolveLocalGrants | ResolveLocalGrantsRequest | LocalGrants | Capabilities advertises contract version 1.9; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least @@ -291,3 +292,7 @@ system toolchain roots, which may therefore be omitted. Discovery is a point-in-time snapshot: hosts must prevent concurrent mutation and creation of protected entries within a granted tree, or use isolated disposable state. It does not install a kernel deny rule for filenames created later. + +Git include aliases retain their logical parent. Empty XDG_CONFIG_HOME is +unset; unsupported ~user/ and %(prefix)/ expansion rejects complete discovery. +[Contract specification](../../../../docs/specs/local-grants.md). diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index cb7eec9a..15459ece 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -85,6 +85,26 @@ async fn compiled_manifest_admits_and_executes_local_grant_discovery() ] ); assert_eq!(result.read_write, Vec::::new()); + for include in ["~other/shared.conf", "%(prefix)/shared.conf"] { + std::fs::write( + home.path().join(".gitconfig"), + format!("[include]\npath = {include}\n"), + )?; + let result = proxy + .call::( + "ResolveLocalGrants", + (tinybox_bus::ResolveLocalGrantsRequest { + home: Some(home.path().to_str().ok_or("home is not UTF-8")?.into()), + config: tinybox_bus::LocalGrantConfig::default(), + protected: tinybox_bus::ProtectedPaths::default(), + },), + ) + .await; + assert!( + matches!(result, Err(tinybus::Error::MethodFailed { ref name, .. }) if name.as_str() == tinybox_bus::UNSUPPORTED_OPERATION), + "{include}: {result:?}" + ); + } proxy.call::<()>("Shutdown", ()).await?; broker_task.abort(); Ok(()) diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db163..b0924f18 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -20,3 +20,5 @@ code snippets when they remove ambiguity, but do not paste entire future files into the plan. See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. + +Local grant discovery: [local-grants.md](local-grants.md). diff --git a/docs/plans/local-grants.md b/docs/plans/local-grants.md new file mode 100644 index 00000000..badca58b --- /dev/null +++ b/docs/plans/local-grants.md @@ -0,0 +1,27 @@ +# Local grant discovery implementation sequence + +[Specification](../specs/local-grants.md). This records the implementation order +and verification for the existing discovery slice. + +1. Define LocalGrantConfig, ProtectedPaths, ResolveLocalGrantsRequest and + LocalGrants in crates/tinybox-bus/src/grants.rs; append ResolveLocalGrants + and contract 1.9 in src/lib.rs, with wire compatibility tests. +2. Implement discovery in crates/tinybox-jail/src/grants/mod.rs and types.rs. + Put alias/component boundaries in floor.rs, bounded subtree admission in + inspection.rs and bounded quote-aware Git include discovery in git_config.rs. +3. Reproduce security and pathname defects before fixes in mod_tests.rs and + git_config_tests.rs. Preserve manual writable precedence only outside fixed + read-only state. Return the workspace Result for unsupported expansion. +4. Dispatch discovery from crates/tinybox-module/src/tinybus_module/mod.rs. + Check input/final grant bounds and UTF-8 without exposing native paths. +5. Build the real cdylib unconditionally with --locked --offline in + crates/tinybox-module/tests/grants_manifest_e2e.rs. Admit its full manifest + and invoke successful discovery plus unsupported-expansion refusal. +6. Keep module README/public method table and this specification aligned. + Run cargo fmt --all -- --check, cargo test --all-features, + cargo clippy --all-targets --all-features -- -D warnings, and + cargo build --all-targets --all-features through the workflow's long runner. + +Discovery implementation is complete subject to review and recorded validation. +Execution, released-module consumption and OpenHuman adapters are subsequent +slices and are not represented as delivered by this prerequisite. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae3..a5a46565 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,3 +21,5 @@ After the specification is accepted, create a linked implementation plan in the contract; production code still belongs under `src/`. See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. + +Local grant discovery: [local-grants.md](local-grants.md). diff --git a/docs/specs/local-grants.md b/docs/specs/local-grants.md new file mode 100644 index 00000000..07cf7b31 --- /dev/null +++ b/docs/specs/local-grants.md @@ -0,0 +1,40 @@ +# Local grant discovery + +Status: implemented contract under review. Owner: TinyBox. +Sequence: [local-grants plan](../plans/local-grants.md). + +Contract 1.9 appends ResolveLocalGrants(ResolveLocalGrantsRequest) -> LocalGrants. +The caller supplies operator home, LocalGrantConfig and ProtectedPaths. +The host owns authorization and the unconditional floor; discovery starts no +process and makes no approval decision. + +Existing extras expand ~/ against the supplied home and canonicalize. +Requested and canonical names must clear component, adjacent-pair, prefix and +credential-root exclusions. Protected prefixes and credential roots also +exclude recursive ancestors, retaining raw, parent-resolved and target aliases. +Directory descendants are inspected at discovery time; IO errors and traversal +bounds (1,000,000 entries, 128 levels) omit the grant. Nested links are not +followed; protected pair-prefix aliases are conservatively refused. + +Toolchain discovery includes existing system roots, operator Rustup/NVM/npm, +piecewise Cargo and admitted Git config/ignore paths. Fixed host executable, +configuration and cached dependency state remain read-only; writable extras +cannot promote it. Mutable toolchains require disposable approved state. +Only the explicit allow_proc toggle grants /proc, read-only. + +Git include discovery keeps logical relative resolution and protects all link +locations. The visited key includes canonical file and logical parent, with +depth eight bounding cycles. Empty XDG_CONFIG_HOME is unset. Quoted markers, +supported escapes and unquoted comments preserve Git pathname semantics. +~user/ and %(prefix)/ expansions are unsupported: the complete request returns +UnsupportedOperation instead of incomplete grants. + +The bus rejects non-UTF-8 targets and more than 64 input or final grants. +Missing/protected individual paths are omitted. Discovery is a snapshot; hosts +must prevent later protected entries or concurrent tree mutation. No kernel deny +rule for future credential filenames is claimed. + +Acceptance: behavioral regressions cover every floor and include correction; +the actual dynamic artifact passes production manifest admission and discovery, +including explicit refusal of unsupported include expansions. No implementation +crate is needed by a host. Execution and host adapters remain separate work. From d5dc940d0c875119b279f09806dee8617ec8a09b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:23:27 +0300 Subject: [PATCH 33/38] test(grants): cover git include continuation and missing include paths Add a test asserting that backslash continuation lines in git include paths are joined, and one asserting that a writable grant for a directory does not grant a git include target that does not yet exist. The existing xdg_root expectation is corrected to match the current behaviour of returning None for a relative config root. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config_tests.rs | 8 +++++++- crates/tinybox-jail/src/grants/mod_tests.rs | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 07917017..73e51040 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -32,6 +32,12 @@ fn empty_xdg_config_home_is_unset_in_git_root_selection() { assert_eq!(xdg_root(None), None); assert_eq!( xdg_root(Some(std::ffi::OsStr::new("config-root"))), - Some(Path::new("config-root")) + None ); } + +#[test] +fn git_include_values_join_continuation_lines() { + let config = format!("[include]\npath = ~/work.{}\nconf\n", char::from(92)); + assert_eq!(include_paths(&config), ["~/work.conf"]); +} diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index 6c4a9b54..6266739d 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -1087,3 +1087,16 @@ fn empty_xdg_config_home_does_not_grant_relative_git_configuration() assert_eq!(builder.grants, JailGrants::default()); Ok(()) } + +#[test] +fn missing_git_include_locations_cannot_be_created_from_writable_grants() +-> Result<(), Box> { + let home = tempfile::tempdir()?; + fs::create_dir(home.path().join("future"))?; + fs::write(home.path().join(".gitconfig"), "[include]\npath = future/config\n")?; + let config = LocalGrantConfig { extra_read_write: vec!["~/future".into()], ..Default::default() }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; + assert_eq!(grants.read_write, Vec::::new()); + assert!(!has(&grants.read_only, &home.path().join("future/config"))); + Ok(()) +} From 104b067fa0fc5d948879305f1c799dc4b02a43cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:27:25 +0300 Subject: [PATCH 34/38] fix(grants): handle git config line continuations and absolute xdg paths Git config include paths now join backslash-continued lines the way Git does, including inside quoted values, so includes split across lines are resolved correctly. Missing include targets are recorded as fixed read-only locations to keep future host Git execution consistent, and XDG_CONFIG_HOME is only honoured when it is an absolute path. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config.rs | 41 ++++++++++++++++++- .../src/grants/git_config_tests.rs | 28 +++++++++++-- crates/tinybox-jail/src/grants/mod_tests.rs | 10 ++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index 80c2a3e9..a0d6f241 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -44,6 +44,9 @@ impl Builder<'_> { depth: usize, visited: &mut HashSet<(PathBuf, PathBuf)>, ) -> tinybox_core::Result<()> { + // Missing include targets still affect future host Git execution. + // Reserve their logical locations without granting absent files. + self.fixed_read_only.push(file.to_path_buf()); let Some(canonical) = self.admit(file, "gitconfig") else { return Ok(()); }; @@ -83,7 +86,7 @@ impl Builder<'_> { pub(super) fn include_paths(config: &str) -> Vec { let mut in_include = false; let mut out = Vec::new(); - for line in config.lines() { + for line in logical_lines(config) { let line = line.trim(); if let Some(section) = line.strip_prefix('[') { let name = section.split([']', ' ', '"']).next().unwrap_or(""); @@ -104,6 +107,40 @@ pub(super) fn include_paths(config: &str) -> Vec { out } +// Git removes a backslash immediately before a newline, including in quoted +// values. Comments and escaped backslashes do not continue the next line. +fn logical_lines(config: &str) -> Vec { + let mut lines = config.lines().peekable(); + let mut out = Vec::new(); + let mut logical = String::new(); + while let Some(line) = lines.next() { + logical.push_str(line); + let mut quoted = false; + let mut escaped = false; + for ch in logical.chars() { + if escaped { + escaped = false; + } else { + match ch { + '\\' => escaped = true, + '"' => quoted = !quoted, + '#' | ';' if !quoted => { + escaped = false; + break; + } + _ => {} + } + } + } + if escaped && lines.peek().is_some() { + logical.pop(); + } else { + out.push(std::mem::take(&mut logical)); + } + } + out +} + // Git comments begin only outside double quotes. Decode Git's supported // escapes and reject malformed values instead of inventing filesystem paths. fn include_value(value: &str) -> Option { @@ -147,5 +184,5 @@ fn include_value(value: &str) -> Option { mod tests; fn xdg_root(value: Option<&std::ffi::OsStr>) -> Option<&Path> { - value.filter(|value| !value.is_empty()).map(Path::new) + value.map(Path::new).filter(|path| path.is_absolute()) } diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 73e51040..6e1b9dbf 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -30,14 +30,34 @@ fn malformed_or_empty_include_values_are_not_granted() { fn empty_xdg_config_home_is_unset_in_git_root_selection() { assert_eq!(xdg_root(Some(std::ffi::OsStr::new(""))), None); assert_eq!(xdg_root(None), None); - assert_eq!( - xdg_root(Some(std::ffi::OsStr::new("config-root"))), - None - ); + assert_eq!(xdg_root(Some(std::ffi::OsStr::new("config-root"))), None); } #[test] fn git_include_values_join_continuation_lines() { let config = format!("[include]\npath = ~/work.{}\nconf\n", char::from(92)); assert_eq!(include_paths(&config), ["~/work.conf"]); + assert_eq!( + include_paths("[include]\npath = \"~/work.\\\nconf\"\n"), + ["~/work.conf"] + ); + assert_eq!( + include_paths("[include]\npath = first # comment\\\npath = second\n"), + ["first", "second"] + ); + assert_eq!( + include_paths("[include]\npath = first\\\\\npath = second\n"), + ["first\\", "second"] + ); + assert_eq!( + include_paths("[include]\npath = dangling\\"), + Vec::::new() + ); +} + +#[test] +fn absolute_xdg_config_home_is_used() -> Result<(), Box> { + let root = tempfile::tempdir()?; + assert_eq!(xdg_root(Some(root.path().as_os_str())), Some(root.path())); + Ok(()) } diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index 6266739d..6aae945e 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -1093,8 +1093,14 @@ fn missing_git_include_locations_cannot_be_created_from_writable_grants() -> Result<(), Box> { let home = tempfile::tempdir()?; fs::create_dir(home.path().join("future"))?; - fs::write(home.path().join(".gitconfig"), "[include]\npath = future/config\n")?; - let config = LocalGrantConfig { extra_read_write: vec!["~/future".into()], ..Default::default() }; + fs::write( + home.path().join(".gitconfig"), + "[include]\npath = future/config\n", + )?; + let config = LocalGrantConfig { + extra_read_write: vec!["~/future".into()], + ..Default::default() + }; let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; assert_eq!(grants.read_write, Vec::::new()); assert!(!has(&grants.read_only, &home.path().join("future/config"))); From fce19b83cd953069d702bbeded1f9374deaa1c80 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:27:57 +0300 Subject: [PATCH 35/38] test(grants): cover gitconfig candidates for empty xdg config home The empty XDG_CONFIG_HOME test now asserts the exact fixed read-only paths instead of expecting none, so it verifies that the home gitconfig and config/git/config candidates are still collected while ambient git/config and git/ignore are not. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/mod_tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index 6aae945e..bcc0dfd0 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -1083,7 +1083,13 @@ fn empty_xdg_config_home_does_not_grant_relative_git_configuration() builder.add_gitconfig_at(home.path(), Some(std::ffi::OsStr::new("")))?; // No HOME config exists. In particular, the empty value must not make // ambient git/config or git/ignore into candidate protected state. - assert_eq!(builder.fixed_read_only, Vec::::new()); + assert_eq!( + builder.fixed_read_only, + [ + home.path().join(".gitconfig"), + home.path().join(".config/git/config") + ] + ); assert_eq!(builder.grants, JailGrants::default()); Ok(()) } From 4313be12a7635929e46262c998c001c48fda7e0e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:36:44 +0300 Subject: [PATCH 36/38] test(git_config): cover includeIf sections with tab separators Add a test asserting that conditional include sections using a horizontal tab between the section name and the quoted condition are parsed, so the tab-separated form stays supported. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config_tests.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 6e1b9dbf..d56cac99 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -61,3 +61,8 @@ fn absolute_xdg_config_home_is_used() -> Result<(), Box> assert_eq!(xdg_root(Some(root.path().as_os_str())), Some(root.path())); Ok(()) } + +#[test] +fn conditional_include_sections_accept_horizontal_tabs() { + assert_eq!(include_paths("[includeIf\t\"gitdir:~/work/\"]\npath = ~/conditional.conf\n"), ["~/conditional.conf"]); +} From 02b10d3869dafdec47f09eac077027410868fe2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:37:20 +0300 Subject: [PATCH 37/38] fix(jail): handle tab separators in git config section headers Section names in git config headers can be separated from the bracketed name by a horizontal tab, which previously caused the include detection to miss such sections. The tab is now treated as a delimiter, and the existing test was reformatted to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config.rs | 2 +- crates/tinybox-jail/src/grants/git_config_tests.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config.rs b/crates/tinybox-jail/src/grants/git_config.rs index a0d6f241..c90aa907 100644 --- a/crates/tinybox-jail/src/grants/git_config.rs +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -89,7 +89,7 @@ pub(super) fn include_paths(config: &str) -> Vec { for line in logical_lines(config) { let line = line.trim(); if let Some(section) = line.strip_prefix('[') { - let name = section.split([']', ' ', '"']).next().unwrap_or(""); + let name = section.split([']', ' ', '\t', '"']).next().unwrap_or(""); in_include = name.eq_ignore_ascii_case("include") || name.eq_ignore_ascii_case("includeif"); continue; diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index d56cac99..0a12c24a 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -64,5 +64,8 @@ fn absolute_xdg_config_home_is_used() -> Result<(), Box> #[test] fn conditional_include_sections_accept_horizontal_tabs() { - assert_eq!(include_paths("[includeIf\t\"gitdir:~/work/\"]\npath = ~/conditional.conf\n"), ["~/conditional.conf"]); + assert_eq!( + include_paths("[includeIf\t\"gitdir:~/work/\"]\npath = ~/conditional.conf\n"), + ["~/conditional.conf"] + ); } From afc2af631ca0d314def1372ee6d7be71d291dea7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:44:02 +0300 Subject: [PATCH 38/38] test(grants): extract discovery assertions into a helper Move the local grant discovery checks out of the end-to-end test into a verify_discovery helper so the assertions can be reused, while the test keeps ownership of broker shutdown and task cleanup. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 15459ece..22f66250 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -58,6 +58,13 @@ async fn compiled_manifest_admits_and_executes_local_grant_discovery() tinybox_bus::OBJECT_PATH, tinybox_bus::INTERFACE, )?; + verify_discovery(&proxy).await?; + proxy.call::<()>("Shutdown", ()).await?; + broker_task.abort(); + Ok(()) +} + +async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box> { let home = tempfile::tempdir()?; std::fs::create_dir(home.path().join("data"))?; let result: tinybox_bus::LocalGrants = proxy @@ -105,8 +112,6 @@ async fn compiled_manifest_admits_and_executes_local_grant_discovery() "{include}: {result:?}" ); } - proxy.call::<()>("Shutdown", ()).await?; - broker_task.abort(); Ok(()) }