Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
25 changes: 20 additions & 5 deletions crates/tinysecurity-policy/src/path_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,34 @@ 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<PathBuf> {
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<PathBuf>,
mut metadata: impl FnMut(&Path) -> std::io::Result<()>,
) -> std::io::Result<PathBuf> {
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);
}
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"));
Expand Down Expand Up @@ -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() {
Expand Down
52 changes: 52 additions & 0 deletions crates/tinysecurity-policy/src/path_scope_contract_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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),
Comment thread
senamakel marked this conversation as resolved.
PathValidationResult::Allowed { .. }
));
}
Ok(())
}
5 changes: 3 additions & 2 deletions docs/specs/immutable-path-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading