From d608bcabf50fcdaf0e00fe831f17a79f86326632 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:40:04 +0300 Subject: [PATCH 1/7] feat: resolve native credential roots --- MODULE.md | 3 + .../tinysecurity-bus/src/credential_roots.rs | 70 ++++++++++++ .../src/credential_roots_tests.rs | 43 +++++++ .../src/device_frame_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 4 +- crates/tinysecurity-bus/src/names.rs | 3 + .../tinysecurity-bus/src/path_batch_tests.rs | 1 + crates/tinysecurity-bus/tests/scoped_wire.rs | 7 +- crates/tinysecurity-bus/tests/wire.rs | 2 +- .../examples/device_native_contract.rs | 40 ++++++- crates/tinysecurity-module/src/adapter.rs | 12 +- crates/tinysecurity-policy/src/floor.rs | 105 ++++++++++++++++++ crates/tinysecurity-policy/src/floor_tests.rs | 76 +++++++++++++ crates/tinysecurity-policy/src/lib.rs | 2 +- crates/tinysecurity-policy/src/path.rs | 1 + docs/module-credential-roots.md | 28 +++++ 16 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 crates/tinysecurity-bus/src/credential_roots.rs create mode 100644 crates/tinysecurity-bus/src/credential_roots_tests.rs create mode 100644 docs/module-credential-roots.md diff --git a/MODULE.md b/MODULE.md index 3a4bb14c..701e55f9 100644 --- a/MODULE.md +++ b/MODULE.md @@ -304,6 +304,9 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c stores, segment pairs, lower-cased absolute prefixes), built from the constants the matcher uses, so a host handing it to a jail grant resolver cannot drift from `ProtectedPath2`. Budget: in memory, sub-millisecond. +- `CredentialRoots2` (`CredentialRootsRequest2` -> `CredentialRootsResponse2`): resolves bounded + native credential-store denial roots from the host's optional operator home; see + [credential roots](docs/module-credential-roots.md). - `SpendAction2` and `PeekBudget2` (`BudgetRequest2` -> `BudgetDecision2`): the registered command scope's hourly ledger, the one `CheckCommand` and `Check::HostAction` spend. For an enabled scope, a read-only tier answers `read_only_mode` before anything is spent; otherwise diff --git a/crates/tinysecurity-bus/src/credential_roots.rs b/crates/tinysecurity-bus/src/credential_roots.rs new file mode 100644 index 00000000..6915f2f2 --- /dev/null +++ b/crates/tinysecurity-bus/src/credential_roots.rs @@ -0,0 +1,70 @@ +//! Native credential-store roots for hosts that must hand path denials to a jail. +use serde::{Deserialize, Serialize}; +use std::{fmt, path::PathBuf}; + +/// Maximum concrete credential roots returned for one home directory. +pub const MAX_CREDENTIAL_ROOTS: usize = 64; +/// Maximum encoded path size accepted by the native credential-root member. +pub const MAX_CREDENTIAL_ROOT_BYTES: usize = 4096; + +/// Request concrete native credential-store roots for an optional operator home. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialRootsRequest2 { + /// Trusted operator home supplied by the host; absent homes yield no roots. + pub home: Option, +} +impl fmt::Debug for CredentialRootsRequest2 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CredentialRootsRequest2") + .field("home", &self.home.as_ref().map(|_| "")) + .finish() + } +} + +/// Native lexical and canonical aliases that a jail must deny. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialRootsResponse2 { + /// Bounded absolute paths, including lexical aliases when canonicalization differs. + pub roots: Vec, +} +impl fmt::Debug for CredentialRootsResponse2 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CredentialRootsResponse2") + .field( + "roots", + &format_args!("", self.roots.len()), + ) + .finish() + } +} + +impl CredentialRootsResponse2 { + /// Validate output bounds and require every root to be absolute and traversal-free. + /// + /// # Errors + /// Returns `InvalidArguments` for an oversized, relative, NUL-containing, or + /// traversal-bearing result. + pub fn validate(&self) -> crate::Result<()> { + if self.roots.len() > MAX_CREDENTIAL_ROOTS + || self.roots.iter().any(|root| { + !root.is_absolute() + || root.as_os_str().len() > MAX_CREDENTIAL_ROOT_BYTES + || root.to_string_lossy().contains('\0') + || root + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + }) + { + return Err(crate::Error::InvalidArguments); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "credential_roots_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-bus/src/credential_roots_tests.rs b/crates/tinysecurity-bus/src/credential_roots_tests.rs new file mode 100644 index 00000000..dde89823 --- /dev/null +++ b/crates/tinysecurity-bus/src/credential_roots_tests.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn absent_home_serializes_as_an_empty_root_request() -> Result<(), Box> { + let request = CredentialRootsRequest2 { home: None }; + assert_eq!( + serde_json::to_value(request)?, + serde_json::json!({ "home": null }) + ); + let response = CredentialRootsResponse2 { roots: Vec::new() }; + response.validate()?; + Ok(()) +} + +#[test] +fn response_rejects_relative_and_unbounded_roots() { + assert!( + CredentialRootsResponse2 { + roots: vec![PathBuf::from(".ssh")], + } + .validate() + .is_err() + ); + assert!( + CredentialRootsResponse2 { + roots: vec![PathBuf::from("/").join("x".repeat(MAX_CREDENTIAL_ROOT_BYTES))], + } + .validate() + .is_err() + ); +} + +#[test] +fn debug_redacts_home_and_resolved_paths() { + let request = CredentialRootsRequest2 { + home: Some(PathBuf::from("/secret/operator")), + }; + let response = CredentialRootsResponse2 { + roots: vec![PathBuf::from("/secret/operator/.ssh")], + }; + assert!(!format!("{request:?}").contains("/secret/operator")); + assert!(!format!("{response:?}").contains("/secret/operator")); +} diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 5dd90e71..0be8ef25 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -42,5 +42,5 @@ fn frame_requests_and_responses_pin_the_confidential_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 23)); + assert_eq!(crate::CONTRACT_VERSION, (1, 24)); } diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 64ad8dab..113c88bf 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -27,6 +27,7 @@ mod audited; mod budget; mod callbacks; mod command_policy; +mod credential_roots; mod egress; mod error; mod flow_trust; @@ -44,6 +45,7 @@ pub use audited::*; pub use budget::*; pub use callbacks::*; pub use command_policy::*; +pub use credential_roots::*; pub use egress::*; pub use error::*; pub use flow_trust::*; @@ -59,7 +61,7 @@ pub use redact::RedactorConfig; pub use scoped_policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 23); +pub const CONTRACT_VERSION: (u32, u32) = (1, 24); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/names.rs b/crates/tinysecurity-bus/src/names.rs index e35c2f0b..03eec5fc 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -75,6 +75,8 @@ pub mod methods { pub const PROTECTED_PATH2: &str = "ProtectedPath2"; /// The floor's component, file, pair and prefix vocabulary (contract 1.22). pub const PROTECTED_FLOOR2: &str = "ProtectedFloor2"; + /// Resolves concrete native credential-store roots for a host-provided home. + pub const CREDENTIAL_ROOTS2: &str = "CredentialRoots2"; /// Reserve one action from a registered command scope's hourly ledger (contract 1.22). pub const SPEND_ACTION2: &str = "SpendAction2"; /// Read a registered command scope's hourly ledger without spending (contract 1.22). @@ -187,6 +189,7 @@ pub const METHODS: &[&str] = &[ methods::CHECK_PATHS2, methods::PROTECTED_PATH2, methods::PROTECTED_FLOOR2, + methods::CREDENTIAL_ROOTS2, methods::SPEND_ACTION2, methods::PEEK_BUDGET2, methods::CLASSIFY_HOST_ACTION2, diff --git a/crates/tinysecurity-bus/src/path_batch_tests.rs b/crates/tinysecurity-bus/src/path_batch_tests.rs index 00e4484f..5e96edbe 100644 --- a/crates/tinysecurity-bus/src/path_batch_tests.rs +++ b/crates/tinysecurity-bus/src/path_batch_tests.rs @@ -52,6 +52,7 @@ fn member_names_are_stable_and_advertised() { (crate::names::methods::CHECK_PATHS2, "CheckPaths2"), (crate::names::methods::PROTECTED_PATH2, "ProtectedPath2"), (crate::names::methods::PROTECTED_FLOOR2, "ProtectedFloor2"), + (crate::names::methods::CREDENTIAL_ROOTS2, "CredentialRoots2"), ] { assert_eq!(name, wire); assert!(crate::names::METHODS.contains(&name)); diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index aaf4128e..d29bc23b 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -83,9 +83,10 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 23)); - // A host built against 1.23 accepts this module and newer minors, not a 1.22 module. - assert!(is_compatible((1, 23))); + assert_eq!(CONTRACT_VERSION, (1, 24)); + // A host built against 1.24 accepts this module and newer minors, not a 1.23 module. + assert!(is_compatible((1, 24))); + assert!(!is_compatible((1, 23))); assert!(!is_compatible((1, 22))); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 20))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index c326c4dd..8a5c1b3a 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (1, 23)); + assert_eq!(CONTRACT_VERSION, (1, 24)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-module/examples/device_native_contract.rs b/crates/tinysecurity-module/examples/device_native_contract.rs index cb1c4547..1b10e34b 100644 --- a/crates/tinysecurity-module/examples/device_native_contract.rs +++ b/crates/tinysecurity-module/examples/device_native_contract.rs @@ -61,6 +61,7 @@ async fn main() -> std::result::Result<(), Box> { let proxy = host .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? .with_timeout(Duration::from_secs(10)); + credential_root_checks(&proxy).await?; routing_checks(&proxy, &bus, &module).await?; let mut sequence = 1; let (key, stale_tunnel) = native_protocols(&proxy, &store, &mut sequence).await?; @@ -105,10 +106,47 @@ async fn main() -> std::result::Result<(), Box> { task.abort(); assert_eq!(hash, tinybus::module::sha256_file(&library)?); println!( - "native device: SHA256={hash}; verified peer + confidential DeviceFrame2 Seal/Open + no frame approval rows + cross-scope/replay/revoke/stale-generation denial + existing approved v2/v3/v4 flows + shutdown passed" + "native device: SHA256={hash}; verified CredentialRoots2 native resolution + home-none isolation + peer + confidential DeviceFrame2 Seal/Open + no frame approval rows + cross-scope/replay/revoke/stale-generation denial + existing approved v2/v3/v4 flows + shutdown passed" ); Ok(()) } + +async fn credential_root_checks( + proxy: &tinybus::Proxy, +) -> std::result::Result<(), Box> { + use tinysecurity_bus::{CredentialRootsRequest2, CredentialRootsResponse2}; + + let home_variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + let home = std::env::var_os(home_variable) + .map(std::path::PathBuf::from) + .ok_or("native fixture home is unavailable")?; + let response: CredentialRootsResponse2 = proxy + .call( + names::methods::CREDENTIAL_ROOTS2, + CredentialRootsRequest2 { + home: Some(home.clone()), + }, + ) + .await?; + response.validate()?; + assert!(response.roots.contains(&home.join(".ssh"))); + #[cfg(target_os = "macos")] + assert!(response.roots.contains(&home.join("Library/Keychains"))); + #[cfg(windows)] + assert!( + response + .roots + .contains(&home.join("AppData/Roaming/Microsoft/Protect")) + ); + let absent: CredentialRootsResponse2 = proxy + .call( + names::methods::CREDENTIAL_ROOTS2, + CredentialRootsRequest2 { home: None }, + ) + .await?; + assert!(absent.roots.is_empty()); + Ok(()) +} async fn native_tunnel( proxy: &tinybus::Proxy, store: &Arc>, diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 662e2a98..3e3b87e4 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -545,6 +545,16 @@ impl PolicyService { async fn protected_floor2(&self) -> BusResult { std::future::ready(Ok(tinysecurity::floor_vocabulary())).await } + async fn credential_roots2( + &self, + request: tinysecurity_bus::CredentialRootsRequest2, + ) -> BusResult { + let roots = tinysecurity::credential_roots(request.home.as_deref()) + .map_err(|error| failure(&error))?; + let response = tinysecurity_bus::CredentialRootsResponse2 { roots }; + response.validate().map_err(|error| failure(&error))?; + std::future::ready(Ok(response)).await + } async fn spend_action2( &self, request: tinysecurity_bus::BudgetRequest2, @@ -1951,7 +1961,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-policy/src/floor.rs b/crates/tinysecurity-policy/src/floor.rs index 01b4d60a..b711888f 100644 --- a/crates/tinysecurity-policy/src/floor.rs +++ b/crates/tinysecurity-policy/src/floor.rs @@ -54,6 +54,111 @@ pub fn floor_vocabulary() -> ProtectedFloor2 { } } +/// Resolve concrete native credential-store roots for a host-provided home. +/// +/// # Errors +/// Returns an error when the home is not an absolute native path or when a +/// filesystem error other than `NotFound` prevents safe canonicalization. +pub fn credential_roots(home: Option<&Path>) -> tinysecurity_bus::Result> { + use std::{collections::BTreeSet, fs}; + use tinysecurity_bus::{ + CredentialRootsResponse2, MAX_CREDENTIAL_ROOT_BYTES, MAX_CREDENTIAL_ROOTS, + }; + + let Some(home) = home else { + return Ok(Vec::new()); + }; + if !home.is_absolute() + || home.as_os_str().len() > MAX_CREDENTIAL_ROOT_BYTES + || home.to_string_lossy().contains('\0') + || home + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(tinysecurity_bus::Error::InvalidArguments); + } + let canonical_home = + fs::canonicalize(home).map_err(|_| tinysecurity_bus::Error::InvalidArguments)?; + let mut candidates = Vec::new(); + for root in [home.to_path_buf(), canonical_home] { + for component in CREDENTIAL_COMPONENTS { + if !CREDENTIAL_FILE_STORES.contains(component) && *component != "keychains" { + candidates.push(root.join(component)); + } + } + for file in CREDENTIAL_FILE_STORES { + candidates.push(root.join(file)); + } + for &(parent, child) in CREDENTIAL_PAIRS { + if parent == ".docker" || parent == ".cargo" { + candidates.push(root.join(parent).join(child)); + } + } + #[cfg(target_os = "macos")] + candidates.push(root.join("Library/Keychains")); + #[cfg(windows)] + for app_data in ["AppData/Roaming", "AppData/Local"] { + for &(parent, child) in CREDENTIAL_PAIRS { + if parent == "microsoft" { + candidates.push(root.join(app_data).join(parent).join(child)); + } + } + } + } + + let mut roots = BTreeSet::new(); + for candidate in candidates { + roots.insert(candidate.clone()); + roots.insert(canonicalize_prospective(&candidate)?); + if roots.len() > MAX_CREDENTIAL_ROOTS { + return Err(tinysecurity_bus::Error::InvalidArguments); + } + } + let response = CredentialRootsResponse2 { + roots: roots.into_iter().collect(), + }; + response.validate()?; + Ok(response.roots) +} + +/// Canonicalize an existing target or its nearest existing ancestor, then append +/// the missing suffix. Other I/O errors fail closed. +fn canonicalize_prospective(path: &Path) -> tinysecurity_bus::Result { + use std::fs; + + let mut probe = path.to_path_buf(); + let mut missing = Vec::new(); + loop { + match fs::canonicalize(&probe) { + Ok(mut canonical) => { + for component in missing.iter().rev() { + canonical.push(component); + } + return Ok(canonical); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match fs::symlink_metadata(&probe) { + Ok(_) => return Err(tinysecurity_bus::Error::InvalidArguments), + Err(metadata_error) + if metadata_error.kind() != std::io::ErrorKind::NotFound => + { + return Err(tinysecurity_bus::Error::InvalidArguments); + } + Err(_) => {} + } + let Some(name) = probe.file_name().map(std::ffi::OsStr::to_os_string) else { + return Err(tinysecurity_bus::Error::InvalidArguments); + }; + missing.push(name); + if !probe.pop() { + return Err(tinysecurity_bus::Error::InvalidArguments); + } + } + Err(_) => return Err(tinysecurity_bus::Error::InvalidArguments), + } + } +} + #[cfg(test)] #[path = "floor_tests.rs"] mod tests; diff --git a/crates/tinysecurity-policy/src/floor_tests.rs b/crates/tinysecurity-policy/src/floor_tests.rs index ca0714b5..253f0da5 100644 --- a/crates/tinysecurity-policy/src/floor_tests.rs +++ b/crates/tinysecurity-policy/src/floor_tests.rs @@ -1,4 +1,74 @@ use super::*; + +#[test] +fn concrete_credential_roots_keep_home_none_isolated_and_resolve_native_paths() +-> Result<(), Box> { + use std::fs; + + assert!(credential_roots(None)?.is_empty()); + let home = tempfile::tempdir()?; + let ssh = home.path().join(".ssh"); + fs::create_dir(&ssh)?; + let aws = home.path().join(".aws"); + fs::create_dir(&aws)?; + fs::write(home.path().join(".netrc"), "fixture")?; + let roots = credential_roots(Some(home.path()))?; + assert!(roots.contains(&fs::canonicalize(&ssh)?)); + assert!(roots.contains(&fs::canonicalize(&aws)?)); + assert!(roots.contains(&home.path().join(".netrc"))); + assert!(roots.iter().all(|root| root.is_absolute())); + assert!(roots.len() <= tinysecurity_bus::MAX_CREDENTIAL_ROOTS); + + #[cfg(target_os = "macos")] + { + let keychains = home.path().join("Library/Keychains"); + fs::create_dir_all(&keychains)?; + let roots = credential_roots(Some(home.path()))?; + assert!(roots.contains(&fs::canonicalize(keychains)?)); + } + + #[cfg(windows)] + { + let protect = home.path().join("AppData/Roaming/Microsoft/Protect"); + fs::create_dir_all(&protect)?; + let roots = credential_roots(Some(home.path()))?; + assert!(roots.contains(&fs::canonicalize(protect)?)); + } + Ok(()) +} + +#[cfg(unix)] +#[test] +fn credential_roots_include_symlink_alias_and_target_and_fail_closed_on_dangling_links() +-> Result<(), Box> { + use std::os::unix::fs::symlink; + + let home = tempfile::tempdir()?; + let target = home.path().join("credential-target"); + std::fs::create_dir(&target)?; + let alias = home.path().join(".ssh"); + symlink(&target, &alias)?; + let roots = credential_roots(Some(home.path()))?; + assert!(roots.contains(&alias)); + assert!(roots.contains(&std::fs::canonicalize(target)?)); + + let dangling_home = tempfile::tempdir()?; + symlink( + dangling_home.path().join("missing"), + dangling_home.path().join(".ssh"), + )?; + assert!(credential_roots(Some(dangling_home.path())).is_err()); + Ok(()) +} + +#[test] +fn credential_roots_reject_relative_and_non_directory_homes() +-> Result<(), Box> { + assert!(credential_roots(Some(Path::new("relative-home"))).is_err()); + let file = tempfile::NamedTempFile::new()?; + assert!(credential_roots(Some(file.path())).is_err()); + Ok(()) +} use std::path::PathBuf; fn floor(path: &str) -> bool { @@ -27,6 +97,12 @@ fn floor_covers_credentials_system_roots_traversal_and_nul() { } } +#[test] +fn floor_protects_windows_ngc_credentials_at_any_depth() { + assert!(floor("/home/u/AppData/Local/Microsoft/Ngc/secret")); + assert!(floor(r"C:\Users\u\AppData\Local\Microsoft\Ngc\secret")); +} + #[test] fn floor_leaves_ordinary_paths_alone() { for path in [ diff --git a/crates/tinysecurity-policy/src/lib.rs b/crates/tinysecurity-policy/src/lib.rs index 75d79791..66ce9965 100644 --- a/crates/tinysecurity-policy/src/lib.rs +++ b/crates/tinysecurity-policy/src/lib.rs @@ -10,7 +10,7 @@ pub use host_tool::scope_ids as host_tool_scope_ids; pub use host_tool::{ScopeConflict, request_scope as host_tool_request_scope}; mod floor; -pub use floor::{floor_vocabulary, lexical_floor}; +pub use floor::{credential_roots, floor_vocabulary, lexical_floor}; mod path_subtree; pub use path_subtree::protected_subtree; mod path_scope; diff --git a/crates/tinysecurity-policy/src/path.rs b/crates/tinysecurity-policy/src/path.rs index e5eb9714..729beb07 100644 --- a/crates/tinysecurity-policy/src/path.rs +++ b/crates/tinysecurity-policy/src/path.rs @@ -35,6 +35,7 @@ pub(super) const CREDENTIAL_PAIRS: &[(&str, &str)] = &[ ("microsoft", "credentials"), ("microsoft", "crypto"), ("microsoft", "vault"), + ("microsoft", "ngc"), (".docker", "config.json"), (".cargo", "credentials.toml"), ]; diff --git a/docs/module-credential-roots.md b/docs/module-credential-roots.md new file mode 100644 index 00000000..bf183ab1 --- /dev/null +++ b/docs/module-credential-roots.md @@ -0,0 +1,28 @@ +# Native credential roots + +Contract 1.24 adds `CredentialRoots2` for a host that must pass unconditional +credential-store denials to a separate jail or grant resolver. The host supplies +its trusted operator home as `CredentialRootsRequest2.home`; `None` returns an +empty list and preserves home-less isolation. The member does not discover a +home from process environment variables. + +The native policy builds candidates from the same credential-store vocabulary +as the lexical floor. It covers the home-level SSH, GnuPG, AWS, Azure and kube +directories; single-file stores; Docker and Cargo credential files; macOS +`Library/Keychains`; and Windows `AppData` Microsoft Credentials, Protect, Crypto, +Vault and Ngc stores. Windows roots include both Roaming and Local containers +where the platform uses them. + +For each candidate, the response includes its lexical path and the canonical +path. Existing symlinks therefore contribute both the supplied alias and its +target. A missing store is resolved through its nearest existing ancestor and +retains the missing suffix, which also protects stores created after planning. +An invalid home, dangling symlink, non-`NotFound` filesystem error, or a result +that exceeds the 64-root and 4096-byte bounds fails closed. Paths are never +included in `Debug` output. + +The result is a finite set of anchored native locations for a host's jail +configuration. Individual path checks continue to use the lexical floor, which +matches credential names at arbitrary depth. A host must pass the module's +result through unchanged and must not replace it with a locally maintained +credential list. From e8b91be238ece642219afa4f58cd17fceea89859 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:44:06 +0300 Subject: [PATCH 2/7] docs: clarify credential root resolution --- crates/tinysecurity-policy/src/floor.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-policy/src/floor.rs b/crates/tinysecurity-policy/src/floor.rs index b711888f..e5d8cbc2 100644 --- a/crates/tinysecurity-policy/src/floor.rs +++ b/crates/tinysecurity-policy/src/floor.rs @@ -1,8 +1,9 @@ //! The unconditional lexical path floor for callers that hold no registered scope //! (contract 1.22 `ProtectedPath2` / `ProtectedFloor2`). //! -//! Purely lexical: no filesystem access, no canonicalization. The vocabulary is built -//! from the very constants the matcher uses, so the two cannot drift. +//! [`lexical_floor`] and [`floor_vocabulary`] are purely lexical and use the same +//! constants. [`credential_roots`] separately resolves concrete native roots with +//! filesystem canonicalization for hosts that must configure a jail deny list. use crate::path::{ CREDENTIAL_COMPONENTS, CREDENTIAL_FILE_STORES, CREDENTIAL_PAIRS, CRITICAL_ROOTS, INSTALLATION_ROOTS, WINDOWS_SYSTEM_ROOTS, strip_native_namespace, From d8a0aa5e3bae600e6de8cdc3b1cf0ae78e27aa0c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 22:56:19 +0300 Subject: [PATCH 3/7] test: keep clippy compatible with stable --- crates/tinysecurity-policy/src/floor_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/floor_tests.rs b/crates/tinysecurity-policy/src/floor_tests.rs index 253f0da5..20548420 100644 --- a/crates/tinysecurity-policy/src/floor_tests.rs +++ b/crates/tinysecurity-policy/src/floor_tests.rs @@ -5,7 +5,7 @@ fn concrete_credential_roots_keep_home_none_isolated_and_resolve_native_paths() -> Result<(), Box> { use std::fs; - assert!(credential_roots(None)?.is_empty()); + assert_eq!(credential_roots(None)?, Vec::::new()); let home = tempfile::tempdir()?; let ssh = home.path().join(".ssh"); fs::create_dir(&ssh)?; From dd5e2d63c2befb1b1f1e1e4e4139a043726e48e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 18 Oct 2026 23:03:22 +0300 Subject: [PATCH 4/7] test: avoid clippy assert-is-empty lint --- crates/tinysecurity-module/examples/device_native_contract.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/examples/device_native_contract.rs b/crates/tinysecurity-module/examples/device_native_contract.rs index 1b10e34b..b792f0db 100644 --- a/crates/tinysecurity-module/examples/device_native_contract.rs +++ b/crates/tinysecurity-module/examples/device_native_contract.rs @@ -144,7 +144,7 @@ async fn credential_root_checks( CredentialRootsRequest2 { home: None }, ) .await?; - assert!(absent.roots.is_empty()); + assert_eq!(absent.roots, Vec::::new()); Ok(()) } async fn native_tunnel( From 0642cf71776d41a38e7c5edb1ff80ecf4f3ce4c0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 00:02:37 +0300 Subject: [PATCH 5/7] test(bus): redact device frame operation payloads --- .../src/device_frame_tests.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 0be8ef25..20767b45 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -39,6 +39,34 @@ fn frame_requests_and_responses_pin_the_confidential_wire_shape() Ok(()) } +#[test] +fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() +-> std::result::Result<(), Box> { + let seal = DeviceFrameOperation2::Seal { + plaintext: b"private seal payload".to_vec(), + }; + let open = DeviceFrameOperation2::Open { + frame: b"private open frame".to_vec(), + }; + + for operation in [&seal, &open] { + let debug = format!("{operation:?}"); + assert_eq!(debug, "DeviceFrameOperation2([REDACTED])"); + assert!(!debug.contains("private")); + } + + let wire = serde_json::to_value(&open)?; + assert_eq!( + wire, + serde_json::json!({ + "kind": "open", + "frame": b"private open frame" + }) + ); + assert_eq!(serde_json::from_value::(wire)?, open); + Ok(()) +} + #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); From 94231ea5987f967fec241572fd1a32693369ce09 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 19 Oct 2026 04:41:43 +0300 Subject: [PATCH 6/7] fix: reject non-directory credential homes --- crates/tinysecurity-policy/src/floor.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/tinysecurity-policy/src/floor.rs b/crates/tinysecurity-policy/src/floor.rs index e5d8cbc2..64455e9b 100644 --- a/crates/tinysecurity-policy/src/floor.rs +++ b/crates/tinysecurity-policy/src/floor.rs @@ -80,6 +80,12 @@ pub fn credential_roots(home: Option<&Path>) -> tinysecurity_bus::Result Date: Mon, 19 Oct 2026 09:14:57 +0300 Subject: [PATCH 7/7] fix: preserve native Windows credential root casing --- MODULE.md | 10 ++++++--- crates/tinysecurity-module/src/adapter.rs | 8 +++++-- crates/tinysecurity-policy/src/floor.rs | 20 +++++++++++------- crates/tinysecurity-policy/src/floor_tests.rs | 21 +++++++++++++++++++ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/MODULE.md b/MODULE.md index 2de22362..3a6440e1 100644 --- a/MODULE.md +++ b/MODULE.md @@ -307,9 +307,6 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c stores, segment pairs, lower-cased absolute prefixes), built from the constants the matcher uses, so a host handing it to a jail grant resolver cannot drift from `ProtectedPath2`. Budget: in memory, sub-millisecond. -- `CredentialRoots2` (`CredentialRootsRequest2` -> `CredentialRootsResponse2`): resolves bounded - native credential-store denial roots from the host's optional operator home; see - [credential roots](docs/module-credential-roots.md). - `SpendAction2` and `PeekBudget2` (`BudgetRequest2` -> `BudgetDecision2`): the registered command scope's hourly ledger, the one `CheckCommand` and `Check::HostAction` spend. For an enabled scope, a read-only tier answers `read_only_mode` before anything is spent; otherwise @@ -330,6 +327,13 @@ cannot bind the call answers an error, never an allow. Latency budgets are per c `Verdict::Deny { reason: DenialReason::Privacy }`, `cacheable: false`, stamped with the live policy generation. Budget: a pure in-memory decision. Refused with required audit. +## Native credential-store roots (contract 1.24) + +- `CredentialRoots2` (`CredentialRootsRequest2` -> `CredentialRootsResponse2`): resolves bounded + native credential-store denial roots from the host's optional operator home; see + [credential roots](docs/module-credential-roots.md). Filesystem canonicalization runs on a + blocking worker so the module's async bus executor remains responsive. + ## Approval origin classes, durable flow trust and scored prompt screening (contract 1.22) Additive: every new member and enum value is absent from a 1.21 host's traffic, and a 1.22 host diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 145ec630..8baa5460 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -550,8 +550,12 @@ impl PolicyService { &self, request: tinysecurity_bus::CredentialRootsRequest2, ) -> BusResult { - let roots = tinysecurity::credential_roots(request.home.as_deref()) - .map_err(|error| failure(&error))?; + let roots = tokio::task::spawn_blocking(move || { + tinysecurity::credential_roots(request.home.as_deref()) + }) + .await + .map_err(|_| tinybus::Error::failed("credential root resolution unavailable"))? + .map_err(|error| failure(&error))?; let response = tinysecurity_bus::CredentialRootsResponse2 { roots }; response.validate().map_err(|error| failure(&error))?; std::future::ready(Ok(response)).await diff --git a/crates/tinysecurity-policy/src/floor.rs b/crates/tinysecurity-policy/src/floor.rs index 64455e9b..015a0287 100644 --- a/crates/tinysecurity-policy/src/floor.rs +++ b/crates/tinysecurity-policy/src/floor.rs @@ -104,13 +104,7 @@ pub fn credential_roots(home: Option<&Path>) -> tinysecurity_bus::Result) -> tinysecurity_bus::Result Vec { + let mut candidates = Vec::new(); + for app_data in ["AppData/Roaming", "AppData/Local"] { + for child in ["Protect", "Credentials", "Crypto", "Vault", "Ngc"] { + candidates.push(root.join(app_data).join("Microsoft").join(child)); + } + } + candidates +} + /// Canonicalize an existing target or its nearest existing ancestor, then append /// the missing suffix. Other I/O errors fail closed. fn canonicalize_prospective(path: &Path) -> tinysecurity_bus::Result { diff --git a/crates/tinysecurity-policy/src/floor_tests.rs b/crates/tinysecurity-policy/src/floor_tests.rs index 20548420..6ed04c47 100644 --- a/crates/tinysecurity-policy/src/floor_tests.rs +++ b/crates/tinysecurity-policy/src/floor_tests.rs @@ -69,6 +69,27 @@ fn credential_roots_reject_relative_and_non_directory_homes() assert!(credential_roots(Some(file.path())).is_err()); Ok(()) } + +#[test] +fn windows_credential_candidates_preserve_native_casing_and_match_floor_pairs() { + let root = Path::new("C:/Users/fixture"); + let candidates = super::windows_credential_candidates(root); + let stores = ["Protect", "Credentials", "Crypto", "Vault", "Ngc"]; + for app_data in ["AppData/Roaming", "AppData/Local"] { + for store in stores { + assert!( + candidates.contains(&root.join(app_data).join("Microsoft").join(store)), + "missing native path for {app_data}/Microsoft/{store}" + ); + assert!( + CREDENTIAL_PAIRS.iter().any(|&(parent, child)| { + parent == "microsoft" && child == store.to_ascii_lowercase() + }), + "{store} has no lowercased floor pair" + ); + } + } +} use std::path::PathBuf; fn floor(path: &str) -> bool {