diff --git a/Cargo.lock b/Cargo.lock index 13ee289a..dd3f1f0e 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..13258b10 --- /dev/null +++ b/crates/tinybox-bus/src/grants.rs @@ -0,0 +1,61 @@ +//! 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. 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, +} + +/// 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 bd8dc704..c4394bd7 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}; @@ -38,8 +41,9 @@ pub use files::{ /// 1.7 adds explicitly approved native directory-jail execution. 1.8 adds a /// bounded, module-owned one-shot Docker operation. 1.9 adds bounded Docker /// daemon status and label-scoped cleanup operations. 1.10 adds a bounded -/// Docker command that does not mount a host workspace. -pub const CONTRACT_VERSION: (u32, u32) = (1, 10); +/// Docker command that does not mount a host workspace. 1.11 adds local grant +/// discovery. +pub const CONTRACT_VERSION: (u32, u32) = (1, 11); /// 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. @@ -112,6 +116,7 @@ pub const METHODS: &[&str] = &[ "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", + "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 ffabe342..cb5d7473 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -47,9 +47,14 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso timeout_ms: 120_000, }; assert!(METHODS.contains(&"RunDocker")); - assert_eq!(METHODS.get(METHODS.len() - 2), Some(&"CleanupDocker")); - assert_eq!(METHODS.last(), Some(&"RunDockerWithoutWorkspace")); - assert_eq!(CONTRACT_VERSION, (1, 10)); + assert_eq!(METHODS.get(METHODS.len() - 3), Some(&"CleanupDocker")); + assert_eq!( + METHODS.get(METHODS.len() - 2), + Some(&"RunDockerWithoutWorkspace") + ); + assert_eq!(METHODS.last(), Some(&"ResolveLocalGrants")); + assert!(METHODS.contains(&"RunDockerWithoutWorkspace")); + assert_eq!(CONTRACT_VERSION, (1, 11)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -127,7 +132,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, 10)); + assert_eq!(CONTRACT_VERSION, (1, 11)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 8))); assert!(!is_compatible((1, 7))); @@ -145,7 +150,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,10],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,11],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -155,8 +160,11 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() } #[test] -fn no_workspace_docker_operation_is_appended_to_the_contract() { - assert_eq!(METHODS.last(), Some(&"RunDockerWithoutWorkspace")); +fn no_workspace_docker_operation_is_present_in_the_contract() { + assert_eq!( + METHODS.get(METHODS.len() - 2), + Some(&"RunDockerWithoutWorkspace") + ); assert_eq!( METHODS .iter() @@ -164,7 +172,7 @@ fn no_workspace_docker_operation_is_appended_to_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (1, 10)); + assert_eq!(CONTRACT_VERSION, (1, 11)); } #[test] @@ -248,3 +256,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-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/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/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-jail/src/grants/floor.rs b/crates/tinybox-jail/src/grants/floor.rs new file mode 100644 index 00000000..5829fb8a --- /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. + 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)) + .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]) + }) + }) + } + + 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| { + 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..755c43b9 --- /dev/null +++ b/crates/tinybox-jail/src/grants/git_config.rs @@ -0,0 +1,189 @@ +//! 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_FILE: &str = ".gitconfig"; + +/// Deepest `include` chain followed when collecting git config files. +const GITCONFIG_MAX_DEPTH: usize = 10; + +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) -> 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>, + ) -> tinybox_core::Result<()> { + let config_home = xdg_root(xdg) + .map(Path::to_path_buf) + .unwrap_or_else(|| home.join(".config")); + let roots = [home.join(GITCONFIG_FILE), config_home.join("git/config")]; + self.fixed_read_only(&config_home.join("git/ignore"), "gitignore"); + let mut visited = HashSet::new(); + for root in roots { + self.add_gitconfig_file(&root, home, 0, &mut visited)?; + } + Ok(()) + } + + fn add_gitconfig_file( + &mut self, + file: &Path, + home: &Path, + 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(()); + }; + if !canonical.is_file() { + 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"); + 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 Ok(()); + }; + // Relative includes resolve against the directory of the file *as + // named*, which for a symlinked config is the link's directory. + 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)?; + } + Ok(()) + } +} + +/// `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 logical_lines(config) { + let line = line.trim(); + let mut assignment = line; + if let Some(section) = line.strip_prefix('[') { + let Some((header, rest)) = section.split_once(']') else { + continue; + }; + let name = header.split([']', ' ', '\t', '"']).next().unwrap_or(""); + in_include = + name.eq_ignore_ascii_case("include") || name.eq_ignore_ascii_case("includeif"); + assignment = rest.trim(); + } + if !in_include { + continue; + } + if let Some((key, value)) = assignment.split_once('=') + && key.trim().eq_ignore_ascii_case("path") + && let Some(value) = include_value(value) + { + out.push(value); + } + } + 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 { + 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) +} + +#[cfg(test)] +#[path = "git_config_tests.rs"] +mod tests; + +fn xdg_root(value: Option<&std::ffi::OsStr>) -> Option<&Path> { + 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 new file mode 100644 index 00000000..74ad90ac --- /dev/null +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -0,0 +1,122 @@ +//! Git include decoding and malformed-value rejection coverage. +use super::*; +use tinybox_bus::ProtectedPaths; + +#[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}"); + } +} + +#[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"))), 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(()) +} + +#[test] +fn absolute_xdg_config_home_replaces_home_config_root() -> Result<(), Box> { + let home = tempfile::tempdir()?; + let xdg = tempfile::tempdir()?; + std::fs::create_dir_all(home.path().join(".config/git"))?; + std::fs::create_dir_all(xdg.path().join("git"))?; + std::fs::write(home.path().join(".config/git/config"), "")?; + std::fs::write(xdg.path().join("git/config"), "")?; + std::fs::write(home.path().join(".config/git/ignore"), "")?; + std::fs::write(xdg.path().join("git/ignore"), "")?; + let protected = ProtectedPaths::default(); + let mut builder = Builder::new(Some(home.path()), &protected); + builder.add_gitconfig_at(home.path(), Some(xdg.path().as_os_str()))?; + assert!( + builder + .fixed_read_only + .contains(&xdg.path().join("git/config")) + ); + assert!( + !builder + .fixed_read_only + .contains(&home.path().join(".config/git/config")) + ); + assert!( + builder + .grants + .read_only + .contains(&xdg.path().join("git/ignore")) + ); + assert!( + !builder + .grants + .read_only + .contains(&home.path().join(".config/git/ignore")) + ); + Ok(()) +} + +#[test] +fn conditional_include_sections_accept_horizontal_tabs() { + assert_eq!( + include_paths("[includeIf\t\"gitdir:~/work/\"]\npath = ~/conditional.conf\n"), + ["~/conditional.conf"] + ); +} + +#[test] +fn include_path_on_section_header_is_discovered() { + assert_eq!( + include_paths("[include] path = ~/shared.conf\n"), + ["~/shared.conf"] + ); + assert_eq!( + include_paths("[includeIf \"gitdir:~/work/\"] path = ../shared.conf\n"), + ["../shared.conf"] + ); +} 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..b0c1c53e --- /dev/null +++ b/crates/tinybox-jail/src/grants/mod.rs @@ -0,0 +1,226 @@ +//! 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). +/// # 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, +) -> 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); + + 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() + ); + Ok(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/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs new file mode 100644 index 00000000..8f1769c5 --- /dev/null +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -0,0 +1,1114 @@ +//! Toolchain grant discovery and protected-path regression coverage. +use super::*; +use std::fs; +use std::path::{Path, PathBuf}; + +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))?; + } + Ok(home) +} + +fn canon(p: &Path) -> std::io::Result { + p.canonicalize() +} + +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() -> 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() -> Result<(), Box> +{ + 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("/"))); + Ok(()) +} + +#[test] +#[cfg(unix)] +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() -> 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))?; + } + for file in ["config.toml", "config", "env"] { + fs::write(cargo.join(file), "")?; + } + 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_only, &cargo.join("registry"))); + assert!(has(&g.read_only, &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" + ); + } + Ok(()) +} + +#[test] +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())?; + 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() -> 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() +-> 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))?; + } + fs::write( + cargo.join("credentials.toml"), + "[registry]\ntoken = \"x\"\n", + )?; + fs::write(cargo.join("config.toml"), "")?; + 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_only, &c.join("registry"))); + assert!(has(&g.read_only, &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() -> 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() + }; + 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)); + Ok(()) +} + +#[test] +#[cfg(target_os = "linux")] +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"))); + + // 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"))); + Ok(()) +} + +#[test] +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!(!all(&g).iter().any(|p| p.ends_with("nope"))); + Ok(()) +} + +#[test] +#[cfg(unix)] +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)?; + 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", + )?; + 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"), "")?; + + 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); + Ok(()) +} + +#[test] +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"))?)); + Ok(()) +} + +#[test] +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 { + 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) -> tinybox_core::Result { + resolve_local_jail_grants(home, config, &floor(home)) +} + +#[test] +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)?; + 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_eq!(grants.read_only, Vec::::new()); + Ok(()) +} + +#[test] +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()], + ..LocalGrantConfig::default() + }; + 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() -> Result<(), Box> { + let config = LocalGrantConfig { + toolchain_homes: false, + extra_read_only: vec![" ".into(), "~".into(), "~/unknown".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve(None, &config)?; + assert_eq!(grants.read_only, Vec::::new()); + Ok(()) +} + +#[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() +-> Result<(), Box> { + let home = fake_home()?; + fs::write( + home.path().join(".gitconfig"), + "[include]\n path = ~/next-0\n path = ~/.cargo\n", + )?; + for index in 0..12 { + fs::write( + home.path().join(format!("next-{index}")), + format!("[include]\n path = ~/next-{}\n", index + 1), + )?; + } + let grants = resolve(Some(home.path()), &LocalGrantConfig::default())?; + assert!(has(&grants.read_only, &canon(&home.path().join("next-9"))?)); + assert!(!has( + &grants.read_only, + &canon(&home.path().join("next-10"))? + )); + 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"))); +} + +#[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() +-> 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())?; + 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() +-> 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())?; + assert_eq!(grants.read_only, vec![PathBuf::from("/proc")]); + assert_eq!(grants.read_write, Vec::::new()); + Ok(()) +} + +#[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(()) +} + +#[test] +fn protected_prefixes_also_reject_recursive_ancestor_grants() +-> 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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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: vec!["~/dotfiles".into()], + ..LocalGrantConfig::default() + }; + let grants = resolve_local_jail_grants(Some(home.path()), &config, &ProtectedPaths::default())?; + 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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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(()) +} + +#[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, + [ + home.path().join(".gitconfig"), + home.path().join(".config/git/config") + ] + ); + 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(()) +} 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, +} 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 d91e0854..43148143 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -44,8 +44,9 @@ 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.10; 1.0 denotes the original +Capabilities advertises contract version 1.11; 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 @@ -98,6 +99,11 @@ existing callers; when true, the command runs through `sh -lc` rather than resource, rootfs, read-only mount, capability-drop, environment, label, output, and timeout behavior remains shared with `RunDocker`. +Version 1.11 adds `ResolveLocalGrants` for discovering canonical read-only and +writable local grants under a caller-supplied protected-path floor. Git +configuration discovery follows Git's XDG config-home selection and supports +include chains up to Git's ten-level limit. + Create requires an explicit backend and rejects one unavailable on the current platform before it creates a resource slot. `passthrough` is record-only on platforms where TinyBox cannot supervise native children; Docker is available @@ -278,3 +284,39 @@ 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.11 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. Both requested names and canonical +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; writable extras cannot promote +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`. + +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. + +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/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 19dafe84..383f7673 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -58,6 +58,69 @@ 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") + })?; + 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, + "too many discovered local grant paths", + )); + } + 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(); @@ -421,7 +484,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", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace"], + 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", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "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 2178bc0e..e7da4c9c 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -896,3 +896,163 @@ 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_eq!(result.read_write, Vec::::new()); + 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(()) +} + +#[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(()) +} + +#[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(()) +} 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..22f66250 --- /dev/null +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -0,0 +1,184 @@ +//! 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 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: {}", + 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, + )?; + 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 + .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()); + 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:?}" + ); + } + 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)) +} + +#[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(()) +} 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.