From 6f7f9ac888950904186083dc1b5d1a6961b12b3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:59:16 +0300 Subject: [PATCH 1/2] fix(policy): fail closed on ancestor metadata and verify configured releases Co-authored-by: Medulla --- .github/workflows/release.yml | 6 +-- crates/tinysecurity-policy/src/path_scope.rs | 25 +++++++-- .../src/path_scope_contract_tests.rs | 52 +++++++++++++++++++ 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd58ba84..dc9221d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -584,8 +584,8 @@ jobs: exit 1 fi - cargo run --manifest-path vendor/tinybus/Cargo.toml --locked \ - --package tinybus --all-features --example github_module_host -- \ - "$release_url" "$archive" "$sha256" + # The generic TinyBus example supplies {}, which is intentionally not + # a valid TinySecurity init config. This verifier uses ModuleConfig + # and the same GitHub loader, then checks real policy bus behavior. cargo run --locked --package tinysecurity-module --example verify_module -- \ "$release_url" "$archive" "$sha256" diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index e20b16f0..95651533 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -59,10 +59,19 @@ pub fn protected_path(path: &Path) -> bool { /// # Errors /// Returns the filesystem error for broken symlinks, permission errors, or missing roots. pub fn resolve_ancestor(path: &Path) -> std::io::Result { + resolve_ancestor_with(path, Path::canonicalize, |path| { + path.symlink_metadata().map(|_| ()) + }) +} +fn resolve_ancestor_with( + path: &Path, + mut canonicalize: impl FnMut(&Path) -> std::io::Result, + mut metadata: impl FnMut(&Path) -> std::io::Result<()>, +) -> std::io::Result { let mut ancestor = path.to_path_buf(); let mut suffix = Vec::new(); loop { - match ancestor.canonicalize() { + match canonicalize(&ancestor) { Ok(mut resolved) => { for part in suffix.iter().rev() { resolved.push(part); @@ -70,11 +79,14 @@ pub fn resolve_ancestor(path: &Path) -> std::io::Result { return Ok(resolved); } Err(error) => { - if error.kind() != std::io::ErrorKind::NotFound - || ancestor.symlink_metadata().is_ok() - { + if error.kind() != std::io::ErrorKind::NotFound { return Err(error); } + match metadata(&ancestor) { + Ok(()) => return Err(error), + Err(probe) if probe.kind() == std::io::ErrorKind::NotFound => {} + Err(probe) => return Err(probe), + } suffix.push(ancestor.file_name().ok_or(error)?.to_os_string()); if !ancestor.pop() { return Err(std::io::Error::other("no resolvable ancestor")); @@ -277,7 +289,10 @@ impl PathScope { let workspace = canonical_or_original(&self.workspace_dir); self.forbidden_paths.iter().any(|entry| { let forbidden = expand(self, entry); - if forbidden.is_absolute() && deny_starts_with(&workspace, &forbidden) { + if forbidden.is_absolute() + && (deny_starts_with(&workspace, &forbidden) + || deny_starts_with(&workspace, &canonical_or_original(&forbidden))) + { return false; } let forbidden = if forbidden.is_absolute() { diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index f98e4518..60cfb06b 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -378,3 +378,55 @@ fn workspace_inside_broad_forbidden_root_keeps_access_but_nested_forbidden_root_ } Ok(()) } + +#[test] +fn ancestor_resolution_propagates_uninspectable_component_errors() { + use std::io::{Error, ErrorKind}; + let root = PathBuf::from("root"); + let target = root.join("component"); + for kind in [ + ErrorKind::PermissionDenied, + ErrorKind::Interrupted, + ErrorKind::Other, + ] { + let result = resolve_ancestor_with( + &target, + |path| { + if path == target { + Err(Error::from(ErrorKind::NotFound)) + } else { + Ok(root.clone()) + } + }, + |_| Err(Error::from(kind)), + ); + assert_eq!(result.map_err(|error| error.kind()), Err(kind)); + } +} + +#[cfg(unix)] +#[test] +fn forbidden_root_alias_containing_workspace_preserves_workspace_precedence() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let parent = root.path().canonicalize()?; + let workspace = parent.join("workspace"); + std::fs::create_dir(&workspace)?; + std::fs::write(workspace.join("file"), "fixture")?; + let alias = parent.join("alias"); + std::os::unix::fs::symlink(&parent, &alias)?; + let mut policy = scope(&workspace)?.policy().clone(); + policy.enabled = true; + policy.workspace_only = true; + policy.forbidden_paths = vec![alias.to_string_lossy().into_owned()]; + let mut registry = crate::PathPolicyRegistry::default(); + let id = registry.register(policy)?; + let scope = registry.get(&id)?; + for write in [false, true] { + assert!(matches!( + scope.validate("file", write), + PathValidationResult::Allowed { .. } + )); + } + Ok(()) +} From cfa4791aeb5d3d1e5db5391a733c5214c4f899cf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 22:27:32 +0300 Subject: [PATCH 2/2] docs(policy): clarify forbidden ancestor alias precedence Co-authored-by: Medulla --- docs/specs/immutable-path-scopes.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index 639ec9c4..f7e59872 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -53,8 +53,9 @@ extended aliases, administrative UNC shares and GLOBALROOT device namespaces. Un acting scopes leave `/usr`, `/bin`, `/dev` and library roots to configurable forbidden roots, preserving explicit grants for legitimate tool workloads. Enabled scopes also protect host state regardless of trusted grants. To preserve the host contract, an absolute forbidden root containing the entire -workspace does not revoke workspace access; a forbidden subtree inside the -workspace still denies access. Trusted grants override configurable forbidden roots only for operations their +workspace does not revoke workspace access, whether configured directly or +through a symlink alias resolving to that ancestor. A forbidden subtree inside +the workspace still denies access. Trusted grants override configurable forbidden roots only for operations their access permits; read-only grants cannot authorize writes outside the workspace. Relative requests resolve against the action root, while relative forbidden roots resolve against the