From 93c2bb492bd84b5e7761f4b213d14816f92d1c97 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:19:07 +0300 Subject: [PATCH 01/15] feat(policy): authorize immutable filesystem scopes over TinyBus Co-authored-by: Medulla --- Cargo.lock | 1 + MODULE.md | 17 +- README.md | 2 + crates/tinysecurity-bus/src/lib.rs | 6 +- crates/tinysecurity-bus/src/names.rs | 18 +- crates/tinysecurity-bus/src/path_policy.rs | 140 +++++++++ crates/tinysecurity-bus/tests/path_wire.rs | 34 +++ crates/tinysecurity-module/Cargo.toml | 5 +- .../examples/verify_module.rs | 63 ++++ crates/tinysecurity-module/src/adapter.rs | 87 +++++- .../tinysecurity-module/src/adapter_tests.rs | 156 ++++++++++ crates/tinysecurity-policy/src/lib.rs | 5 + crates/tinysecurity-policy/src/path.rs | 59 ++-- .../tinysecurity-policy/src/path_registry.rs | 82 +++++ .../src/path_registry_tests.rs | 123 ++++++++ crates/tinysecurity-policy/src/path_scope.rs | 283 ++++++++++++++++++ .../src/path_scope_tests.rs | 273 +++++++++++++++++ docs/specs/immutable-path-scopes.md | 55 ++++ 18 files changed, 1368 insertions(+), 41 deletions(-) create mode 100644 crates/tinysecurity-bus/src/path_policy.rs create mode 100644 crates/tinysecurity-bus/tests/path_wire.rs create mode 100644 crates/tinysecurity-policy/src/path_registry.rs create mode 100644 crates/tinysecurity-policy/src/path_registry_tests.rs create mode 100644 crates/tinysecurity-policy/src/path_scope.rs create mode 100644 crates/tinysecurity-policy/src/path_scope_tests.rs create mode 100644 docs/specs/immutable-path-scopes.md diff --git a/Cargo.lock b/Cargo.lock index 3351d3e1..f263a121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -550,6 +550,7 @@ name = "tinysecurity-module" version = "0.2.1" dependencies = [ "serde_json", + "tempfile", "tinybus", "tinybus-module", "tinysecurity", diff --git a/MODULE.md b/MODULE.md index c32edd34..6f18101c 100644 --- a/MODULE.md +++ b/MODULE.md @@ -2,7 +2,8 @@ The artifact is `tinysecurity-module`, exporting TinyBus ABI v1. Its interface is `ai.tinyhumans.tinysecurity.Policy`, with object -`/ai/tinyhumans/tinysecurity/Policy` and methods `Evaluate`, `Check`, `PolicyInfo`. +`/ai/tinyhumans/tinysecurity/Policy`. Its methods are `Evaluate`, `Check`, +`PolicyInfo`, `RegisterPathPolicy`, `ValidatePath`, `ValidateParent`, and `CheckPath`. Accepted SDK reinit advances a generation and emits `PolicyChanged`; rejected configurations retain the old policy and generation. There are no Init/Reinit RPC methods. Configuration requires `contract_version` and `policy`. The optional @@ -10,7 +11,7 @@ RPC methods. Configuration requires `contract_version` and `policy`. The optiona ```json { - "contract_version": [1, 0], + "contract_version": [1, 1], "policy": { "enabled": false, "action_dir": "", @@ -56,12 +57,22 @@ argument-free `pwd`, `date`, `uname`, `whoami` through a trusted host executable search path. Compound commands, expansions, heredocs, arguments, executable paths and unrecognized commands deny conservatively on every OS. -Enabled writes deny with `approval_unavailable`; no unusable request IDs are +Enabled bootstrap Evaluate/Check writes deny with `approval_unavailable`; no unusable request IDs are minted. Unknown tools/effects deny. Cron writes deny even with disabled policy. Readonly callers cannot write. Decisions include generation and are uncached. The host must reject stale generations and never retry/fallback after module faults/timeouts. SDK faults remain latched; ordinary denials do not fault. +## Immutable filesystem scopes + +Contract 1.1's filesystem members register independent immutable agent scopes. +They accept host-owned native roots and state reservations, preserve scope IDs +across SDK reinit, and authorize canonical reads or prospective writes without +requesting approval. Approval gating remains the caller's separate responsibility. +See [the scope contract](docs/specs/immutable-path-scopes.md) for configurable +floor differences from conservative bootstrap checks, registry bounds, symlink +handling and read-only grants. These members do not add command grammar parity. + ## Reserved capabilities Approval lifecycle/flow/reply, redaction/scanning, privacy/URL guard, sandbox, diff --git a/README.md b/README.md index bfc08c41..602c7045 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ TinySecurity supplies security policy through a native TinyBus module. Hosts compile the transport-free `tinysecurity-bus` contract and load the isolated `tinysecurity-module` artifact. The bootstrap implements `Evaluate`, batched `Check`, `PolicyInfo`, validated SDK configuration and policy generations. +Contract 1.1 also supplies immutable native [filesystem scopes](docs/specs/immutable-path-scopes.md) +through `RegisterPathPolicy`, `ValidatePath`, `ValidateParent` and `CheckPath`. The engine denies unknown tools and unsupported effects. Argument-free `pwd`, `date`, `uname`, and `whoami` are its supported shell diagnostics. Paths pass diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 7c603c06..7706934f 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -1,16 +1,18 @@ //! Versioned, transport-free `TinySecurity` wire contract. //! //! Hosts link this crate; the policy engine and native module stay isolated. -//! Only Evaluate, Check and `PolicyInfo` are implemented in the bootstrap. +//! Implements bootstrap checks plus immutable native filesystem scope contracts. mod error; mod future; +mod path_policy; mod policy; pub use error::*; pub use future::*; +pub use path_policy::*; pub use policy::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 0); +pub const CONTRACT_VERSION: (u32, u32) = (1, 1); /// 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 9c2cdbec..e246697b 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -11,6 +11,14 @@ pub mod methods { pub const CHECK: &str = "Check"; /// `PolicyInfo` member. pub const POLICY_INFO: &str = "PolicyInfo"; + /// Register an immutable filesystem policy scope. + pub const REGISTER_PATH_POLICY: &str = "RegisterPathPolicy"; + /// Resolve and authorize an existing read path. + pub const VALIDATE_PATH: &str = "ValidatePath"; + /// Resolve and authorize a prospective write path. + pub const VALIDATE_PARENT: &str = "ValidateParent"; + /// Boolean filesystem policy query. + pub const CHECK_PATH: &str = "CheckPath"; /// Decide member. pub const DECIDE: &str = "Decide"; /// `ListPending` member. @@ -39,7 +47,15 @@ pub mod methods { pub const AUDIT: &str = "Audit"; } /// Bootstrap implemented methods only. -pub const METHODS: &[&str] = &[methods::EVALUATE, methods::CHECK, methods::POLICY_INFO]; +pub const METHODS: &[&str] = &[ + methods::EVALUATE, + methods::CHECK, + methods::REGISTER_PATH_POLICY, + methods::VALIDATE_PATH, + methods::VALIDATE_PARENT, + methods::CHECK_PATH, + methods::POLICY_INFO, +]; /// Accepted policy activation event. pub const POLICY_CHANGED: &str = "PolicyChanged"; /// Reserved host callback identities. diff --git a/crates/tinysecurity-bus/src/path_policy.rs b/crates/tinysecurity-bus/src/path_policy.rs new file mode 100644 index 00000000..016a7e20 --- /dev/null +++ b/crates/tinysecurity-bus/src/path_policy.rs @@ -0,0 +1,140 @@ +//! Immutable native filesystem policy scopes registered by the trusted host. +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +/// Access granted to a trusted filesystem root. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathAccess { + /// Reading only. + ReadOnly, + /// Reading and writing. + ReadWrite, +} +/// Host-authorized root and its operation access. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathTrustedRoot { + /// Native filesystem root. + pub path: PathBuf, + /// Allowed operations. + pub access: PathAccess, +} +/// A reserved subtree, with explicitly readable child exceptions. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathReservation { + /// Reserved path, including descendants. + pub path: PathBuf, + /// Reserve descendants while allowing the container directory itself. + #[serde(default)] + pub children_only: bool, + /// Subtrees excluded from this reservation. + pub exceptions: Vec, +} +/// A host-owned state directory family matched by its immediate child's prefix. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathReservedPrefix { + /// Directory containing the reserved children. + pub root: PathBuf, + /// Nonempty prefix; the child must also have a nonempty suffix. + pub prefix: String, +} +/// Immutable per-agent/per-turn filesystem policy. No process environment is read. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathPolicy { + /// Whether configurable restrictions apply; the unconditional floor always applies. + pub enabled: bool, + /// Reject absolute requests outside trusted roots. + pub workspace_only: bool, + /// Host internal-state root; also resolves relative forbidden entries. + pub workspace_dir: PathBuf, + /// Acting directory for relative requests. + pub action_dir: PathBuf, + /// Trusted home directory used to expand leading `~/`. + pub home_dir: Option, + /// Configured forbidden paths; trusted roots override these only. + pub forbidden_paths: Vec, + /// Explicitly granted filesystem access. + pub trusted_roots: Vec, + /// Host state reservations; grants never override these. + pub reserved_paths: Vec, + /// Host state directory families. + pub reserved_prefixes: Vec, + /// Paths readable but never writable when policy is enabled. + pub readonly_paths: Vec, + /// Reserved single-component relative filenames. + pub reserved_names: Vec, +} +/// Opaque immutable registered scope identity. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathPolicyId(pub String); +/// Path validation invocation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathValidationRequest { + /// Registered immutable scope. + pub policy_id: PathPolicyId, + /// User-supplied native path. + pub path: String, +} +/// Stable content-free path rejection categories. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathErrorCategory { + /// Unconditional credential/system/traversal floor. + Protected, + /// Configurable policy restriction. + PolicyDenied, + /// Canonicalization failed or target is missing. + Resolution, + /// Input has no usable filename or parent. + InvalidPath, +} +/// Typed path validation result; no platform error text crosses the boundary. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum PathValidationResult { + /// Validated native canonical path. + Allowed { + /// Resolved path. + resolved: PathBuf, + }, + /// Rejected before filesystem I/O. + Denied { + /// Stable rejection category. + category: PathErrorCategory, + }, +} +/// Boolean helper operation for host adapters that need synchronous checks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathCheckKind { + /// Lexical and existing-ancestor checks. + StringAllowed, + /// Resolved read containment. + ResolvedRead, + /// Resolved write containment. + ResolvedWrite, + /// Host internal-state reservation membership. + Internal, + /// Trusted read-root membership. + TrustedRead, + /// Trusted write-root membership. + TrustedWrite, + /// Unconditional floor membership. + Protected, +} +/// Boolean filesystem policy query. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathCheckRequest { + /// Registered immutable scope. + pub policy_id: PathPolicyId, + /// Native input path. + pub path: PathBuf, + /// Query kind. + pub kind: PathCheckKind, +} diff --git a/crates/tinysecurity-bus/tests/path_wire.rs b/crates/tinysecurity-bus/tests/path_wire.rs new file mode 100644 index 00000000..5252bfbf --- /dev/null +++ b/crates/tinysecurity-bus/tests/path_wire.rs @@ -0,0 +1,34 @@ +//! Golden filesystem contract shapes and malformed invocation rejection. +use tinysecurity_bus::*; +#[test] +fn path_results_and_requests_have_stable_native_wire_shapes() +-> std::result::Result<(), Box> { + let request = PathValidationRequest { + policy_id: PathPolicyId("path-scope-0".into()), + path: "project/file".into(), + }; + let fixture = r#"{"policy_id":"path-scope-0","path":"project/file"}"#; + assert_eq!(serde_json::to_string(&request)?, fixture); + assert_eq!( + serde_json::from_str::(fixture)?, + request + ); + let denied = PathValidationResult::Denied { + category: PathErrorCategory::Protected, + }; + let fixture = r#"{"status":"denied","category":"protected"}"#; + assert_eq!(serde_json::to_string(&denied)?, fixture); + assert_eq!( + serde_json::from_str::(fixture)?, + denied + ); + assert!( + serde_json::from_str::( + r#"{"policy_id":"p","path":"x","allow":true}"# + ) + .is_err() + ); + assert!(serde_json::from_str::(r#"{"status":"allowed"}"#).is_err()); + assert!(!is_compatible((1, 0))); + Ok(()) +} diff --git a/crates/tinysecurity-module/Cargo.toml b/crates/tinysecurity-module/Cargo.toml index 4cb3ee03..7368c8e0 100644 --- a/crates/tinysecurity-module/Cargo.toml +++ b/crates/tinysecurity-module/Cargo.toml @@ -19,10 +19,13 @@ tinysecurity-bus = { workspace = true } tinybus = { workspace = true } tinybus-module = { workspace = true } serde_json = { workspace = true } +# Filesystem policy runs on blocking workers without blocking the module runtime. +tokio = { workspace = true } [dev-dependencies] +# Disposable native filesystem policy scopes. +tempfile.workspace = true serde_json = { workspace = true } -tokio = { workspace = true } [lints] workspace = true diff --git a/crates/tinysecurity-module/examples/verify_module.rs b/crates/tinysecurity-module/examples/verify_module.rs index ef0208a7..36119c09 100644 --- a/crates/tinysecurity-module/examples/verify_module.rs +++ b/crates/tinysecurity-module/examples/verify_module.rs @@ -89,7 +89,9 @@ async fn main() -> std::result::Result<(), Box> { .await .is_err() ); + let path_scope = register_native_scope(&proxy).await?; verify_reconfiguration(&host, &proxy, &info.name).await?; + verify_native_scope(&proxy, path_scope).await?; let evaluate = EvaluateRequest { caller, call: ToolCall { @@ -197,3 +199,64 @@ async fn verify_reconfiguration( assert_eq!(after.generation, before.generation + 1); Ok(()) } + +async fn register_native_scope( + proxy: &tinybus::Proxy, +) -> std::result::Result> { + let root = std::env::current_dir()?.canonicalize()?; + let policy = tinysecurity_bus::PathPolicy { + enabled: true, + workspace_only: true, + workspace_dir: root.clone(), + action_dir: root, + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + Ok(proxy + .call(names::methods::REGISTER_PATH_POLICY, (policy,)) + .await?) +} +async fn verify_native_scope( + proxy: &tinybus::Proxy, + policy_id: tinysecurity_bus::PathPolicyId, +) -> std::result::Result<(), Box> { + use tinysecurity_bus::{ + PathCheckKind, PathCheckRequest, PathValidationRequest, PathValidationResult, + }; + let request = PathValidationRequest { + policy_id: policy_id.clone(), + path: "Cargo.toml".into(), + }; + let read: PathValidationResult = proxy + .call(names::methods::VALIDATE_PATH, (request,)) + .await?; + assert!(matches!(read, PathValidationResult::Allowed { .. })); + let write: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PARENT, + (PathValidationRequest { + policy_id: policy_id.clone(), + path: "target/prospective/path/file".into(), + },), + ) + .await?; + assert!(matches!(write, PathValidationResult::Allowed { .. })); + let protected: bool = proxy + .call( + names::methods::CHECK_PATH, + (PathCheckRequest { + policy_id, + path: "/etc/passwd".into(), + kind: PathCheckKind::Protected, + },), + ) + .await?; + assert!(protected); + println!("native immutable path scope survives accepted SDK reinitialization"); + Ok(()) +} diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 4a47103c..720bcdd1 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1,19 +1,40 @@ //! SDK-configured native policy service, with no lifecycle RPC members. -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; use tinybus::{Connection, Result as BusResult}; -use tinysecurity::Policy; +use tinysecurity::{PathPolicyRegistry, Policy}; use tinysecurity_bus::{ CheckRequest, CheckResponse, Decision, EvaluateRequest, ModuleConfig, PolicyInfo, names, }; +static PATH_SCOPES: OnceLock>> = OnceLock::new(); static ACTIVE: OnceLock>> = OnceLock::new(); +#[derive(Clone)] struct PolicyService { policy: Arc>, + paths: Arc>, } fn failure(error: &tinysecurity_bus::Error) -> tinybus::Error { tinybus::Error::failed(error.to_string()) } impl PolicyService { + async fn run_path(&self, id: tinysecurity_bus::PathPolicyId, operation: F) -> BusResult + where + T: Send + 'static, + F: FnOnce(&tinysecurity::PathScope) -> T + Send + 'static, + { + let scope = self + .paths + .lock() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? + .get_owned(&id) + .map_err(|error| failure(&error))?; + // Drop the registry lock before filesystem I/O. A stalled mount must + // neither serialize other agents nor block the sole SDK runtime worker. + tokio::task::spawn_blocking(move || operation(&scope)) + .await + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted)) + } + fn read(&self) -> BusResult> { self.policy .read() @@ -23,21 +44,56 @@ impl PolicyService { #[tinybus::interface(name = "ai.tinyhumans.tinysecurity.Policy")] impl PolicyService { async fn evaluate(&self, request: EvaluateRequest) -> BusResult { - let result = { - self.read()? - .evaluate(&request) - .map_err(|error| failure(&error)) - }; - std::future::ready(result).await + let policy = self.read()?.clone(); + tokio::task::spawn_blocking(move || { + policy.evaluate(&request).map_err(|error| failure(&error)) + }) + .await + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? } async fn check(&self, request: CheckRequest) -> BusResult { - let result = { - self.read()? - .check(&request) - .map_err(|error| failure(&error)) - }; + let policy = self.read()?.clone(); + tokio::task::spawn_blocking(move || policy.check(&request).map_err(|error| failure(&error))) + .await + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? + } + async fn register_path_policy( + &self, + policy: tinysecurity_bus::PathPolicy, + ) -> BusResult { + let result = self + .paths + .lock() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? + .register(policy) + .map_err(|error| failure(&error)); std::future::ready(result).await } + async fn validate_path( + &self, + request: tinysecurity_bus::PathValidationRequest, + ) -> BusResult { + self.run_path(request.policy_id, move |scope| { + scope.validate(&request.path, false) + }) + .await + } + async fn validate_parent( + &self, + request: tinysecurity_bus::PathValidationRequest, + ) -> BusResult { + self.run_path(request.policy_id, move |scope| { + scope.validate(&request.path, true) + }) + .await + } + async fn check_path(&self, request: tinysecurity_bus::PathCheckRequest) -> BusResult { + self.run_path(request.policy_id, move |scope| { + scope.check_path(&request.path, request.kind) + }) + .await + } + async fn policy_info(&self) -> BusResult { let info = { self.read()?.info() }; std::future::ready(Ok(info)).await @@ -76,6 +132,9 @@ async fn setup(connection: Connection, config: ModuleConfig) -> BusResult<()> { names::OBJECT_PATH.try_into()?, PolicyService { policy: Arc::clone(&policy), + paths: Arc::clone( + PATH_SCOPES.get_or_init(|| Arc::new(Mutex::new(PathPolicyRegistry::default()))), + ), }, ) .await?; @@ -90,7 +149,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["Evaluate", "Check", "PolicyInfo"], + methods = ["Evaluate", "Check", "PolicyInfo", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath"], signals = ["PolicyChanged"], requires = [], optional = [], diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index 6b883c7f..03db18f9 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -7,6 +7,7 @@ fn only_implemented_members_are_advertised() -> std::result::Result<(), Box PathPolicy { + PathPolicy { + enabled: true, + workspace_only: true, + workspace_dir: root.into(), + action_dir: root.into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + } +} + +async fn verify_scope_dispatch( + proxy: &tinybus::Proxy, +) -> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + std::fs::write(root.path().join("file"), "x")?; + let id: PathPolicyId = proxy + .call( + names::methods::REGISTER_PATH_POLICY, + (fixture_policy(root.path()),), + ) + .await?; + let read: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PATH, + (PathValidationRequest { + policy_id: id.clone(), + path: "file".into(), + },), + ) + .await?; + assert!(matches!(read, PathValidationResult::Allowed { .. })); + let protected: bool = proxy + .call( + names::methods::CHECK_PATH, + (PathCheckRequest { + policy_id: id.clone(), + path: "D:\\Windows\\file".into(), + kind: PathCheckKind::Protected, + },), + ) + .await?; + assert!(protected); + assert!( + proxy + .call::( + names::methods::VALIDATE_PATH, + (PathValidationRequest { + policy_id: PathPolicyId("unknown".into()), + path: "file".into() + },) + ) + .await + .is_err() + ); + let invalid = PathPolicy { + workspace_dir: "relative".into(), + ..fixture_policy(root.path()) + }; + assert!( + proxy + .call::(names::methods::REGISTER_PATH_POLICY, (invalid,)) + .await + .is_err() + ); + Ok(()) +} +#[tokio::test(flavor = "current_thread")] +async fn blocked_filesystem_query_does_not_block_policy_info_or_another_scope() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let service = PolicyService { + policy: Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), + }; + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let server = Connection::connect(bus.connect().await?).await?; + server + .serve_at(names::OBJECT_PATH.try_into()?, service.clone()) + .await?; + server.request_name(names::INTERFACE).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let first = service + .register_path_policy(fixture_policy(root.path())) + .await?; + let mut second = fixture_policy(root.path()); + second.enabled = false; + let second = service.register_path_policy(second).await?; + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocked = service.clone(); + let pending = tokio::spawn(async move { + blocked + .run_path(first, move |_| { + let _ = started_tx.send(()); + let _ = release_rx.recv(); + true + }) + .await + }); + started_rx.await?; + if service.paths.try_lock().is_err() { + release_tx.send(())?; + let _ = pending.await?; + return Err(std::io::Error::other("filesystem query retained the registry lock").into()); + } + // Release regardless of assertions so a failing test cannot strand a worker. + let responses = tokio::time::timeout(std::time::Duration::from_secs(2), async { + let info: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + let result: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PARENT, + (PathValidationRequest { + policy_id: second, + path: "new/file".into(), + },), + ) + .await?; + tinybus::Result::Ok((info, result)) + }) + .await; + release_tx.send(())?; + assert!(pending.await??); + broker.abort(); + let (info, result) = responses??; + assert_eq!(info.generation, 1); + assert!(matches!(result, PathValidationResult::Allowed { .. })); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/lib.rs b/crates/tinysecurity-policy/src/lib.rs index 3b7b7cb8..248cf50f 100644 --- a/crates/tinysecurity-policy/src/lib.rs +++ b/crates/tinysecurity-policy/src/lib.rs @@ -5,3 +5,8 @@ mod command; mod engine; mod path; pub use engine::Policy; + +mod path_scope; +pub use path_scope::{PathScope, protected_path, resolve_ancestor}; +mod path_registry; +pub use path_registry::PathPolicyRegistry; diff --git a/crates/tinysecurity-policy/src/path.rs b/crates/tinysecurity-policy/src/path.rs index 8470ad49..78d1cb6c 100644 --- a/crates/tinysecurity-policy/src/path.rs +++ b/crates/tinysecurity-policy/src/path.rs @@ -5,27 +5,11 @@ pub(super) fn floor(path: &str) -> bool { let normalized = path.to_ascii_lowercase().replace('\\', "/"); // Windows canonicalize emits an extended-length prefix. It must not hide // the system-root floor when this engine runs natively on Windows. - let normalized = normalized - .strip_prefix("//?/") - .or_else(|| normalized.strip_prefix("/??/")) - .unwrap_or(&normalized); + let normalized = strip_native_namespace(&normalized); let segments: Vec<_> = normalized.split('/').filter(|s| !s.is_empty()).collect(); // Both slash dialects are checked on every OS. The additional macOS // /private aliases are protected before canonicalization too. - if path.contains('\0') - || segments.contains(&"..") - || segments.iter().any(|s| { - let component = s - .split(':') - .next() - .unwrap_or(s) - .trim_end_matches([' ', '.']); - [".ssh", ".gnupg", ".aws", ".azure", ".kube", "keychains"].contains(&component) - }) - || segments.windows(2).any(|w| { - w[0] == "microsoft" && matches!(w[1], "protect" | "credentials" | "crypto" | "vault") - }) - { + if path.contains('\0') || segments.contains(&"..") || credential_store(&segments) { return true; } [ @@ -49,11 +33,46 @@ pub(super) fn floor(path: &str) -> bool { .any(|prefix| normalized == *prefix || normalized.starts_with(&format!("{prefix}/"))) || windows_system_root(normalized) } +pub(super) fn strip_native_namespace(path: &str) -> &str { + path.strip_prefix("//?/") + .or_else(|| path.strip_prefix("//./")) + .or_else(|| path.strip_prefix("/??/")) + .unwrap_or(path) +} +pub(super) fn credential_store(segments: &[&str]) -> bool { + let normalized: Vec<_> = segments + .iter() + .map(|segment| { + segment + .split(':') + .next() + .unwrap_or(segment) + .trim_end_matches([' ', '.']) + }) + .collect(); + normalized.iter().any(|component| { + [".ssh", ".gnupg", ".aws", ".azure", ".kube", "keychains"].contains(component) + }) || normalized.windows(2).any(|pair| { + pair[0] == "microsoft" && matches!(pair[1], "protect" | "credentials" | "crypto" | "vault") + }) +} + // Match Windows system roots on every drive, including native/extended aliases. // This is lexical on every OS so a foreign path cannot evade the floor. -fn windows_system_root(path: &str) -> bool { +pub(super) fn windows_system_root(path: &str) -> bool { let components: Vec<_> = path.split('/').filter(|part| !part.is_empty()).collect(); - let root_index = if components.first().is_some_and(|part| { + let components = components + .strip_prefix(&["globalroot"]) + .unwrap_or(&components); + let components = components.strip_prefix(&["unc"]).unwrap_or(components); + let admin_share = |component: &&str| { + let bytes = component.trim_end_matches([' ', '.']).as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b'$' + }; + let root_index = if components.get(1).is_some_and(admin_share) { + // UNC server + administrative drive share; canonical UNC retains this form. + 2 + } else if components.first().is_some_and(|part| { let bytes = part.as_bytes(); (bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') || part.starts_with("volume{") diff --git a/crates/tinysecurity-policy/src/path_registry.rs b/crates/tinysecurity-policy/src/path_registry.rs new file mode 100644 index 00000000..28435655 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_registry.rs @@ -0,0 +1,82 @@ +//! Bounded immutable scopes; registration never changes another agent's policy. +use crate::PathScope; +use std::sync::Arc; +use tinysecurity_bus::{Error, PathPolicy, PathPolicyId, Result}; +/// Registry of immutable host-authorized scopes, preserved across SDK reinit. +#[derive(Debug, Default)] +pub struct PathPolicyRegistry { + scopes: Vec>, +} +impl PathPolicyRegistry { + /// Register or retrieve an identical immutable policy. + /// + /// # Errors + /// Rejects malformed policy roots and a full registry (1024 distinct scopes). + pub fn register(&mut self, policy: PathPolicy) -> Result { + if !policy.workspace_dir.is_absolute() + || !policy.action_dir.is_absolute() + || policy + .trusted_roots + .iter() + .any(|root| !root.path.is_absolute()) + || policy.reserved_paths.iter().any(|root| { + !root.path.is_absolute() + || root + .exceptions + .iter() + .any(|path| !path.is_absolute() || !path.starts_with(&root.path)) + }) + || policy + .reserved_prefixes + .iter() + .any(|root| !root.root.is_absolute() || root.prefix.is_empty()) + || policy.readonly_paths.iter().any(|path| !path.is_absolute()) + || policy + .home_dir + .as_ref() + .is_some_and(|home| !home.is_absolute()) + { + return Err(Error::InvalidArguments); + } + if let Some(index) = self.scopes.iter().position(|scope| scope.0 == policy) { + return Ok(PathPolicyId(format!("path-scope-{index}"))); + } + if self.scopes.len() >= 1024 { + return Err(Error::Unavailable); + } + let index = self.scopes.len(); + self.scopes.push(Arc::new(PathScope(policy))); + Ok(PathPolicyId(format!("path-scope-{index}"))) + } + /// Clone an immutable scope handle without cloning its configuration. + /// + /// # Errors + /// Rejects unknown or malformed identifiers. + pub fn get_owned(&self, id: &PathPolicyId) -> Result> { + let index = + id.0.strip_prefix("path-scope-") + .and_then(|index| index.parse::().ok()) + .ok_or(Error::InvalidArguments)?; + self.scopes + .get(index) + .cloned() + .ok_or(Error::InvalidArguments) + } + /// Retrieve only a previously registered immutable scope. + /// + /// # Errors + /// Rejects unknown or malformed identifiers. + pub fn get(&self, id: &PathPolicyId) -> Result<&PathScope> { + let index = + id.0.strip_prefix("path-scope-") + .and_then(|index| index.parse::().ok()) + .ok_or(Error::InvalidArguments)?; + self.scopes + .get(index) + .map(AsRef::as_ref) + .ok_or(Error::InvalidArguments) + } +} +#[cfg(test)] +#[path = "path_registry_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-policy/src/path_registry_tests.rs b/crates/tinysecurity-policy/src/path_registry_tests.rs new file mode 100644 index 00000000..52d02c82 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_registry_tests.rs @@ -0,0 +1,123 @@ +use super::*; +#[test] +fn malformed_roots_and_unknown_scope_ids_are_rejected() { + let mut registry = PathPolicyRegistry::default(); + assert!(registry.get(&PathPolicyId("bogus".into())).is_err()); + assert!( + registry + .get(&PathPolicyId("path-scope-999".into())) + .is_err() + ); + let policy = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: "relative".into(), + action_dir: "relative".into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + assert_eq!(registry.register(policy), Err(Error::InvalidArguments)); +} +#[test] +fn distinct_scope_limit_is_fail_closed_and_existing_scope_remains_usable() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let mut registry = PathPolicyRegistry::default(); + let mut policy = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: root.path().into(), + action_dir: root.path().into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let first = registry.register(policy.clone())?; + for index in 1..1024 { + policy.reserved_names = vec![format!("reserved-{index}")]; + registry.register(policy.clone())?; + } + policy.reserved_names = vec!["overflow".into()]; + assert_eq!(registry.register(policy), Err(Error::Unavailable)); + assert!(registry.get(&first).is_ok()); + Ok(()) +} + +#[test] +fn each_registered_root_kind_must_be_native_absolute() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let base = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: root.path().into(), + action_dir: root.path().into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let mut variants = Vec::new(); + let mut p = base.clone(); + p.action_dir = "relative".into(); + variants.push(p); + let mut p = base.clone(); + p.home_dir = Some("relative".into()); + variants.push(p); + let mut p = base.clone(); + p.trusted_roots.push(tinysecurity_bus::PathTrustedRoot { + path: "relative".into(), + access: tinysecurity_bus::PathAccess::ReadWrite, + }); + variants.push(p); + let mut p = base.clone(); + p.reserved_paths.push(tinysecurity_bus::PathReservation { + path: "relative".into(), + children_only: false, + exceptions: vec![], + }); + variants.push(p); + let mut p = base.clone(); + p.reserved_paths.push(tinysecurity_bus::PathReservation { + path: root.path().join("reserved"), + children_only: false, + exceptions: vec![root.path().join("outside")], + }); + variants.push(p); + let mut p = base.clone(); + p.reserved_prefixes + .push(tinysecurity_bus::PathReservedPrefix { + root: "relative".into(), + prefix: "x".into(), + }); + variants.push(p); + let mut p = base.clone(); + p.reserved_prefixes + .push(tinysecurity_bus::PathReservedPrefix { + root: root.path().into(), + prefix: String::new(), + }); + variants.push(p); + let mut p = base; + p.readonly_paths.push("relative".into()); + variants.push(p); + for policy in variants { + assert_eq!( + PathPolicyRegistry::default().register(policy), + Err(Error::InvalidArguments) + ); + } + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs new file mode 100644 index 00000000..043634b4 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -0,0 +1,283 @@ +//! Native filesystem authorization for immutable host scopes. +use std::path::{Path, PathBuf}; +use tinysecurity_bus::{ + PathAccess, PathCheckKind, PathErrorCategory as Rejection, PathPolicy, PathValidationResult, +}; + +fn expand(scope: &PathPolicy, path: &str) -> PathBuf { + path.strip_prefix("~/") + .and_then(|tail| { + scope.home_dir.as_ref().map(|home| { + tail.split('/').filter(|part| !part.is_empty()).fold( + home.clone(), + |mut path, part| { + path.push(part); + path + }, + ) + }) + }) + .unwrap_or_else(|| PathBuf::from(path)) +} +/// Unconditional filesystem correctness and credential/system floor. +#[must_use] +pub fn protected_path(path: &Path) -> bool { + let normalized = path + .to_string_lossy() + .to_ascii_lowercase() + .replace('\\', "/"); + let normalized = crate::path::strip_native_namespace(&normalized); + let segments: Vec<_> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + if normalized.contains('\0') + || normalized.contains("..%2f") + || normalized.contains("%2f..") + || segments.contains(&"..") + || crate::path::credential_store(&segments) + { + return true; + } + [ + "/etc", + "/root", + "/boot", + "/proc", + "/sys", + "/system", + "/private/etc", + "/private/var/root", + ] + .iter() + .any(|prefix| normalized == *prefix || normalized.starts_with(&format!("{prefix}/"))) + || crate::path::windows_system_root(normalized) +} +/// Resolve prospective paths through their deepest existing ancestor. +/// +/// # Errors +/// Returns the filesystem error for broken symlinks, permission errors, or missing roots. +pub fn resolve_ancestor(path: &Path) -> std::io::Result { + let mut ancestor = path.to_path_buf(); + let mut suffix = Vec::new(); + loop { + match ancestor.canonicalize() { + 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() + { + return Err(error); + } + suffix.push(ancestor.file_name().ok_or(error)?.to_os_string()); + if !ancestor.pop() { + return Err(std::io::Error::other("no resolvable ancestor")); + } + } + } + } +} +fn canonical_or_original(path: &Path) -> PathBuf { + resolve_ancestor(path).unwrap_or_else(|_| path.to_path_buf()) +} +fn within(path: &Path, root: &Path) -> bool { + path.starts_with(root) || path.starts_with(canonical_or_original(root)) +} +/// Compiled immutable filesystem scope. +#[derive(Clone, Debug)] +pub struct PathScope(pub PathPolicy); +impl std::ops::Deref for PathScope { + type Target = PathPolicy; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl PathScope { + /// Whether the path is under a host state reservation, including aliased/missing descendants. + #[must_use] + pub fn internal(&self, path: &Path) -> bool { + let resolved = canonical_or_original(path); + self.reserved_paths.iter().any(|reservation| { + within(&resolved, &reservation.path) + && (!reservation.children_only + || resolved != canonical_or_original(&reservation.path)) + && !reservation + .exceptions + .iter() + .any(|exception| within(&resolved, exception)) + }) || self.reserved_prefixes.iter().any(|reservation| { + let root = canonical_or_original(&reservation.root); + resolved + .strip_prefix(&root) + .ok() + .and_then(|relative| relative.components().next()) + .is_some_and(|component| { + let name = component.as_os_str().to_string_lossy(); + name.strip_prefix(&reservation.prefix) + .is_some_and(|suffix| !suffix.is_empty()) + }) + }) + } + /// Whether an explicitly trusted root grants this operation. + #[must_use] + pub fn trusted(&self, path: &Path, write: bool) -> bool { + !protected_path(path) + && self.trusted_roots.iter().any(|root| { + (!write || root.access == PathAccess::ReadWrite) && within(path, &root.path) + }) + } + /// Resolved-path containment check, independent of approval policy. + #[must_use] + pub fn resolved_allowed(&self, path: &Path, write: bool) -> bool { + if protected_path(path) { + return false; + } + if !self.enabled { + return true; + } + if self.internal(path) + || (write && self.readonly_paths.iter().any(|root| within(path, root))) + { + return false; + } + within(path, &self.workspace_dir) || self.trusted(path, write) + } + fn forbidden(&self, path: &Path) -> bool { + if !self.enabled || self.trusted(path, false) { + return false; + } + let workspace = canonical_or_original(&self.workspace_dir); + self.forbidden_paths.iter().any(|entry| { + let forbidden = expand(self, entry); + if forbidden.is_absolute() && workspace.starts_with(&forbidden) { + return false; + } + let forbidden = if forbidden.is_absolute() { + forbidden + } else { + workspace.join(forbidden) + }; + within(path, &forbidden) + }) + } + fn absolute(&self, input: &str) -> PathBuf { + let expanded = expand(self, input); + if expanded.is_absolute() { + expanded + } else { + self.action_dir.join(expanded) + } + } + /// String and existing-ancestor policy checks; actual I/O must also use validation. + #[must_use] + pub fn string_allowed(&self, input: &str) -> bool { + let expanded = expand(self, input); + if protected_path(&expanded) { + return false; + } + if !expanded.is_absolute() + && expanded.components().count() == 1 + && self.reserved_names.iter().any(|name| { + expanded + .file_name() + .is_some_and(|file| file == std::ffi::OsStr::new(name)) + }) + { + return false; + } + let absolute = self.absolute(input); + if protected_path(&absolute) { + return false; + } + if !self.enabled { + return resolve_ancestor(&absolute).map_or(true, |resolved| !protected_path(&resolved)); + } + if self.internal(&absolute) { + return false; + } + let trusted = self.trusted(&expanded, false); + if self.workspace_only && expanded.is_absolute() && !trusted { + return false; + } + if !trusted + && self + .forbidden_paths + .iter() + .any(|entry| expanded.starts_with(expand(self, entry))) + { + return false; + } + if let Ok(resolved) = resolve_ancestor(&absolute) { + if protected_path(&resolved) || self.internal(&resolved) { + return false; + } + if self.workspace_only + && !within(&resolved, &self.workspace_dir) + && !self.trusted(&resolved, false) + { + return false; + } + if !within(&resolved, &self.workspace_dir) && self.forbidden(&resolved) { + return false; + } + } + true + } + /// Validate an existing read target or prospective write target. + #[must_use] + pub fn validate(&self, input: &str, write: bool) -> PathValidationResult { + let result = self.validate_inner(input, write); + match result { + Ok(resolved) => PathValidationResult::Allowed { resolved }, + Err(category) => PathValidationResult::Denied { category }, + } + } + fn validate_inner(&self, input: &str, write: bool) -> std::result::Result { + if protected_path(&expand(self, input)) { + return Err(Rejection::Protected); + } + if !self.string_allowed(input) { + return Err(Rejection::PolicyDenied); + } + let absolute = self.absolute(input); + let resolved = if write { + let parent = absolute.parent().ok_or(Rejection::InvalidPath)?; + let filename = absolute.file_name().ok_or(Rejection::InvalidPath)?; + // Existing target symlinks must also be checked: writing follows them. + if absolute.symlink_metadata().is_ok() { + absolute.canonicalize().map_err(|_| Rejection::Resolution)? + } else { + resolve_ancestor(parent) + .map_err(|_| Rejection::Resolution)? + .join(filename) + } + } else { + absolute.canonicalize().map_err(|_| Rejection::Resolution)? + }; + if protected_path(&resolved) { + return Err(Rejection::Protected); + } + if !self.resolved_allowed(&resolved, write) || self.forbidden(&resolved) { + return Err(Rejection::PolicyDenied); + } + Ok(resolved) + } + /// Evaluate a content-free boolean path query. + #[must_use] + pub fn check_path(&self, path: &Path, kind: PathCheckKind) -> bool { + match kind { + PathCheckKind::StringAllowed => self.string_allowed(&path.to_string_lossy()), + PathCheckKind::ResolvedRead => self.resolved_allowed(path, false), + PathCheckKind::ResolvedWrite => self.resolved_allowed(path, true), + PathCheckKind::Internal => self.internal(path), + PathCheckKind::TrustedRead => self.trusted(path, false), + PathCheckKind::TrustedWrite => self.trusted(path, true), + PathCheckKind::Protected => protected_path(path), + } + } +} +#[cfg(test)] +#[path = "path_scope_tests.rs"] +mod tests; diff --git a/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs new file mode 100644 index 00000000..8378c9ac --- /dev/null +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -0,0 +1,273 @@ +use super::*; +use tinysecurity_bus::{PathAccess, PathReservation, PathTrustedRoot}; +fn scope(root: &Path) -> PathScope { + PathScope(PathPolicy { + enabled: true, + workspace_only: true, + workspace_dir: root.into(), + action_dir: root.into(), + home_dir: Some(root.into()), + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![".env".into()], + }) +} +#[test] +fn prospective_write_resolves_missing_descendants() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let scope = scope(root.path()); + assert!(matches!( + scope.validate("new/deep/file.txt", true), + PathValidationResult::Allowed { .. } + )); + assert_eq!( + scope.validate("new/deep/file.txt", false), + PathValidationResult::Denied { + category: Rejection::Resolution + } + ); + Ok(()) +} +#[test] +fn reserved_paths_override_grants_but_allow_readonly_exceptions() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut scope = scope(root.path()); + let artifacts = root.path().join("artifacts"); + let results = artifacts.join("tool-results"); + std::fs::create_dir_all(&results)?; + std::fs::write(results.join("result.txt"), "result")?; + scope.0.reserved_paths.push(PathReservation { + path: artifacts.clone(), + children_only: true, + exceptions: vec![results.clone()], + }); + scope.0.readonly_paths.push(results); + scope.0.trusted_roots.push(PathTrustedRoot { + path: root.path().into(), + access: PathAccess::ReadWrite, + }); + assert!(!scope.internal(&artifacts)); + assert!(scope.internal(&artifacts.join("new/meta.json"))); + assert!(matches!( + scope.validate("artifacts/tool-results/result.txt", false), + PathValidationResult::Allowed { .. } + )); + assert_eq!( + scope.validate("artifacts/tool-results/new.txt", true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + Ok(()) +} +#[test] +fn disabled_scope_keeps_floor_and_reserved_single_filenames() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut scope = scope(root.path()); + scope.0.enabled = false; + assert!(!scope.string_allowed("../outside")); + assert!(!scope.string_allowed(".ssh/key")); + assert!(!scope.string_allowed(".env")); + assert!(scope.string_allowed("ordinary/new/file")); + Ok(()) +} +#[cfg(unix)] +#[test] +fn symlink_to_reserved_state_is_denied_even_with_missing_suffix() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let internal = root.path().join("state"); + std::fs::create_dir(&internal)?; + std::os::unix::fs::symlink(&internal, root.path().join("alias"))?; + let mut scope = scope(root.path()); + scope.0.reserved_paths.push(PathReservation { + path: internal, + children_only: false, + exceptions: vec![], + }); + assert_eq!( + scope.validate("alias/new/deep/file", true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + Ok(()) +} +#[test] +fn read_only_grant_never_authorizes_a_write_outside_workspace() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let mut scope = scope(root.path()); + scope.0.trusted_roots.push(PathTrustedRoot { + path: outside.path().into(), + access: PathAccess::ReadOnly, + }); + std::fs::write(outside.path().join("existing"), "x")?; + assert!(matches!( + scope.validate(&outside.path().join("existing").to_string_lossy(), false), + PathValidationResult::Allowed { .. } + )); + assert_eq!( + scope.validate(&outside.path().join("new").to_string_lossy(), true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + Ok(()) +} +#[test] +fn immutable_registration_deduplicates_without_changing_other_agents() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut registry = crate::PathPolicyRegistry::default(); + let first = scope(root.path()).0; + let first_id = registry.register(first.clone())?; + assert_eq!(first_id, registry.register(first.clone())?); + let mut second = first; + second.enabled = false; + let second_id = registry.register(second)?; + assert_ne!(first_id, second_id); + assert!(registry.get(&first_id)?.enabled); + assert!(!registry.get(&second_id)?.enabled); + Ok(()) +} +#[test] +fn absolute_requests_and_relative_forbidden_roots_preserve_policy_scope() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut scope = scope(root.path()); + std::fs::create_dir(root.path().join("blocked"))?; + std::fs::write(root.path().join("blocked/file"), "x")?; + scope.0.forbidden_paths = vec!["blocked".into()]; + assert!(!scope.string_allowed("blocked/file")); + assert!(!scope.string_allowed(&root.path().join("ordinary").to_string_lossy())); + scope.0.workspace_only = false; + assert!(scope.string_allowed(&root.path().join("ordinary").to_string_lossy())); + assert_eq!( + scope.validate("blocked/file", false), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + assert!(!scope.resolved_allowed(Path::new("/etc/passwd"), false)); + assert!(!scope.trusted(Path::new("/etc/passwd"), false)); + assert!(protected_path(Path::new("C:\\Windows\\file"))); + assert!(protected_path(Path::new("/a/../b"))); + assert!(protected_path(Path::new("/a/.ssh/key"))); + assert!(protected_path(Path::new("/a/Microsoft/Vault/key"))); + assert!(protected_path(Path::new("/a/..%2fb"))); + assert!(!protected_path(Path::new("/usr/local/project"))); + Ok(()) +} +#[test] +fn home_expansion_prefix_reservations_and_boolean_queries() -> Result<(), Box> +{ + let root = tempfile::tempdir()?; + let mut scope = scope(root.path()); + scope.0.trusted_roots.push(PathTrustedRoot { + path: root.path().into(), + access: PathAccess::ReadWrite, + }); + scope + .0 + .reserved_prefixes + .push(tinysecurity_bus::PathReservedPrefix { + root: root.path().into(), + prefix: "memory-".into(), + }); + assert!(scope.internal(&root.path().join("memory-alice/new/file"))); + assert!(!scope.internal(&root.path().join("memory-/file"))); + assert!(scope.string_allowed("~/ordinary")); + for kind in [ + PathCheckKind::StringAllowed, + PathCheckKind::ResolvedRead, + PathCheckKind::ResolvedWrite, + PathCheckKind::TrustedRead, + PathCheckKind::TrustedWrite, + ] { + assert!(scope.check_path(&root.path().join("ordinary"), kind)); + } + assert!(scope.check_path(&root.path().join("memory-alice"), PathCheckKind::Internal)); + assert!(scope.check_path(Path::new("/etc/passwd"), PathCheckKind::Protected)); + scope.0.enabled = false; + assert!(scope.resolved_allowed(Path::new("/ordinary/outside"), true)); + Ok(()) +} +#[cfg(unix)] +#[test] +fn existing_write_symlink_and_broken_symlink_are_fail_closed() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let mut scope = scope(root.path()); + scope.0.trusted_roots.push(PathTrustedRoot { + path: outside.path().into(), + access: PathAccess::ReadOnly, + }); + let target = outside.path().join("target"); + std::fs::write(&target, "x")?; + std::os::unix::fs::symlink(&target, root.path().join("alias"))?; + assert_eq!( + scope.validate("alias", true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + std::os::unix::fs::symlink(root.path().join("absent"), root.path().join("broken"))?; + assert_eq!( + scope.validate("broken/new", true), + PathValidationResult::Denied { + category: Rejection::Resolution + } + ); + assert!(resolve_ancestor(&root.path().join("broken")).is_err()); + Ok(()) +} +#[test] +fn every_windows_drive_and_native_extended_alias_preserves_the_floor() { + for drive in b'A'..=b'Z' { + for root in [ + "Windows", + "Program Files", + "Program Files (x86)", + "ProgramData", + ] { + for prefix in ["", "\\\\?\\", "\\??\\"] { + assert!(protected_path(Path::new(&format!( + "{prefix}{}:\\{root}\\file", + char::from(drive) + )))); + } + } + } + assert!(protected_path(Path::new("\\\\server\\share\\.ssh\\id_rsa"))); + assert!(protected_path(Path::new("D:\\users\\alice\\.ssh.\\key"))); + assert!(protected_path(Path::new("D:\\users\\alice\\.ssh:stream"))); +} +#[test] +fn windows_administrative_shares_and_device_namespaces_never_hide_system_roots() { + for path in [ + r"\\server\C$\Windows\System32\config\SAM", + r"\\.\D:\Windows\System32\config\SAM", + r"\\server\C$.\Windows\System32\config\SAM", + r"\\?\UNC\server\D$\Windows\System32\config\SAM", + r"\\?\GLOBALROOT\Device\HarddiskVolume1\Windows\System32\config\SAM", + r"C:\users\alice\Microsoft.\Protect\key", + r"C:\users\alice\Microsoft\Protect.\key", + ] { + assert!( + protected_path(Path::new(path)), + "unprotected namespace: {path}" + ); + assert!( + crate::path::floor(path), + "bootstrap floor must agree: {path}" + ); + } +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md new file mode 100644 index 00000000..0014f8cd --- /dev/null +++ b/docs/specs/immutable-path-scopes.md @@ -0,0 +1,55 @@ +# Immutable native filesystem scopes + +Status: implemented. Owner: TinySecurity. Contract vocabulary: 1.1. + +OpenHuman needs independent filesystem authorization for every acting agent and +turn. Changing SDK module configuration for each request would make concurrent +agents share whichever configuration was installed last. A path scope therefore +has its own immutable identity, independent of the bootstrap policy generation. + +`RegisterPathPolicy` accepts the trusted host's native workspace, action and home +roots, enabled flag, workspace-only setting, forbidden paths, operation-specific +trusted roots, host-state reservations and read-only subtrees. Product-specific +state directory names remain in host configuration. The engine never reads the +process's home environment or imports a host crate. + +The registry deduplicates equal configurations and accepts at most 1024 distinct +scopes per module process. Registration never replaces an existing scope. A full +registry fails closed; SDK reinitialization preserves registered scopes. A new +module process requires fresh registration. Unknown identities fail as invalid +arguments. Scope handles are cloned under the registry lock; filesystem work +runs on blocking workers after releasing it, keeping the module runtime and +unrelated agent requests responsive during a stalled filesystem operation. + +`ValidatePath` resolves an existing read target. `ValidateParent` resolves a +prospective write through its deepest existing ancestor, including missing parent +directories. Existing target symlinks are resolved too: a writable alias cannot +turn a read-only grant into write access. Broken symlinks and permission errors +are failures, not missing descendants. Both return an `Allowed` native path or +`Denied` with a stable category. `CheckPath` exposes boolean string, resolved, +reservation, trusted-root and unconditional-floor queries for host adapters. + +The unconditional credential, system-root, traversal and null-byte floor applies +even with policy disabled. System paths include every Windows drive and native +extended aliases, administrative UNC shares and GLOBALROOT device namespaces. Unlike the conservative bootstrap Check path contract, portable +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. Trusted grants override configurable forbidden roots; read-only +grants cannot authorize writes outside the workspace. Relative requests resolve +against the action root, while relative forbidden roots resolve against the +workspace root. Reserved single-component filenames retain the host's lexical +reservation contract. Reservations may allow child exceptions and may reserve +only children so an artifact container remains listable. Read-only subtrees +remain unwritable through a broad workspace grant. + +Authorization is distinct from approval. These methods neither prompt nor park +a tool invocation. Their canonical-path results do not make later filesystem I/O +atomic: a host still needs an execution sandbox to bound filesystem races. + +Acceptance coverage includes missing targets, aliased reserved state, disabled +policy floors, home expansion, relative forbidden roots, independent registry +identities, registry exhaustion, read-only grants and existing/broken symlinks. +The real native loader verifier calls every new member and verifies that a scope +survives an accepted SDK reinitialization. This completes filesystem capability +migration support; command policy, redaction, approvals, egress, audit and crypto +remain separate capabilities. From 42fceac1adf94df1822458427b7b18803d15ba53 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:38:56 +0300 Subject: [PATCH 02/15] test(bus): pin every immutable filesystem payload shape Co-authored-by: Medulla --- crates/tinysecurity-bus/tests/path_wire.rs | 118 +++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/tinysecurity-bus/tests/path_wire.rs b/crates/tinysecurity-bus/tests/path_wire.rs index 5252bfbf..c03d405b 100644 --- a/crates/tinysecurity-bus/tests/path_wire.rs +++ b/crates/tinysecurity-bus/tests/path_wire.rs @@ -32,3 +32,121 @@ fn path_results_and_requests_have_stable_native_wire_shapes() assert!(!is_compatible((1, 0))); Ok(()) } + +#[test] +fn immutable_policy_and_nested_roots_have_stable_wire_shapes() +-> std::result::Result<(), Box> { + let policy = PathPolicy { + enabled: true, + workspace_only: false, + workspace_dir: "workspace".into(), + action_dir: "action".into(), + home_dir: Some("home".into()), + forbidden_paths: vec!["private".into()], + trusted_roots: vec![ + PathTrustedRoot { + path: "read".into(), + access: PathAccess::ReadOnly, + }, + PathTrustedRoot { + path: "write".into(), + access: PathAccess::ReadWrite, + }, + ], + reserved_paths: vec![PathReservation { + path: "artifacts".into(), + children_only: true, + exceptions: vec!["tool-results".into()], + }], + reserved_prefixes: vec![PathReservedPrefix { + root: "state".into(), + prefix: "memory-".into(), + }], + readonly_paths: vec!["tool-results".into()], + reserved_names: vec!["config.toml".into()], + }; + let fixture = r#"{"enabled":true,"workspace_only":false,"workspace_dir":"workspace","action_dir":"action","home_dir":"home","forbidden_paths":["private"],"trusted_roots":[{"path":"read","access":"read_only"},{"path":"write","access":"read_write"}],"reserved_paths":[{"path":"artifacts","children_only":true,"exceptions":["tool-results"]}],"reserved_prefixes":[{"root":"state","prefix":"memory-"}],"readonly_paths":["tool-results"],"reserved_names":["config.toml"]}"#; + assert_eq!(serde_json::to_string(&policy)?, fixture); + assert_eq!(serde_json::from_str::(fixture)?, policy); + let mut without_home = policy.clone(); + without_home.home_dir = None; + let fixture = fixture.replace(r#""home_dir":"home""#, r#""home_dir":null"#); + assert_eq!(serde_json::to_string(&without_home)?, fixture); + assert_eq!(serde_json::from_str::(&fixture)?, without_home); + let reservation: PathReservation = serde_json::from_str(r#"{"path":"state","exceptions":[]}"#)?; + assert!(!reservation.children_only); + assert_eq!( + serde_json::to_string(&reservation)?, + r#"{"path":"state","children_only":false,"exceptions":[]}"# + ); + let mut unknown: serde_json::Value = serde_json::from_str(&fixture)?; + unknown["host_override"] = true.into(); + assert!(serde_json::from_value::(unknown).is_err()); + Ok(()) +} + +#[test] +fn every_boolean_query_kind_has_a_stable_request_representation() +-> std::result::Result<(), Box> { + for (kind, name) in [ + (PathCheckKind::StringAllowed, "string_allowed"), + (PathCheckKind::ResolvedRead, "resolved_read"), + (PathCheckKind::ResolvedWrite, "resolved_write"), + (PathCheckKind::Internal, "internal"), + (PathCheckKind::TrustedRead, "trusted_read"), + (PathCheckKind::TrustedWrite, "trusted_write"), + (PathCheckKind::Protected, "protected"), + ] { + let request = PathCheckRequest { + policy_id: PathPolicyId("scope".into()), + path: "file".into(), + kind, + }; + let fixture = format!(r#"{{"policy_id":"scope","path":"file","kind":"{name}"}}"#); + assert_eq!(serde_json::to_string(&request)?, fixture); + assert_eq!(serde_json::from_str::(&fixture)?, request); + } + assert!( + serde_json::from_str::( + r#"{"policy_id":"scope","path":"file","kind":"unknown"}"#, + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn allowed_paths_and_every_denial_category_have_stable_result_representations() +-> std::result::Result<(), Box> { + let allowed = PathValidationResult::Allowed { + resolved: "resolved-file".into(), + }; + let fixture = r#"{"status":"allowed","resolved":"resolved-file"}"#; + assert_eq!(serde_json::to_string(&allowed)?, fixture); + assert_eq!( + serde_json::from_str::(fixture)?, + allowed + ); + for (category, name) in [ + (PathErrorCategory::Protected, "protected"), + (PathErrorCategory::PolicyDenied, "policy_denied"), + (PathErrorCategory::Resolution, "resolution"), + (PathErrorCategory::InvalidPath, "invalid_path"), + ] { + let denied = PathValidationResult::Denied { category }; + let fixture = format!(r#"{{"status":"denied","category":"{name}"}}"#); + assert_eq!(serde_json::to_string(&denied)?, fixture); + assert_eq!( + serde_json::from_str::(&fixture)?, + denied + ); + } + assert_eq!( + serde_json::to_string(&PathPolicyId("scope".into()))?, + r#""scope""# + ); + assert!(serde_json::from_str::( + r#"{"status":"denied","category":"unknown"}"#, + ).is_err()); + Ok(()) +} From 4daf6104c155c5a54d2c9ca7c3eeba3796301fd9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:39:29 +0300 Subject: [PATCH 03/15] fix(policy): resolve native authorization queries and preserve scoped restrictions Co-authored-by: Medulla --- Cargo.lock | 52 +++++- Cargo.toml | 4 + crates/tinysecurity-policy/Cargo.toml | 2 + .../tinysecurity-policy/src/path_registry.rs | 60 ++++--- .../src/path_registry_tests.rs | 66 ++++++++ crates/tinysecurity-policy/src/path_scope.rs | 116 +++++++++---- .../src/path_scope_tests.rs | 156 ++++++++++++++++++ docs/specs/immutable-path-scopes.md | 22 ++- 8 files changed, 425 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f263a121..b50b9f05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -172,7 +184,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -283,6 +295,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -562,12 +580,20 @@ dependencies = [ name = "tinysecurity-policy" version = "0.2.1" dependencies = [ + "getrandom 0.3.4", "serde_json", "tempfile", "thiserror", "tinysecurity-bus", + "unicode-normalization", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.1" @@ -666,6 +692,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -713,6 +748,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -816,6 +860,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index a71d5377..cb14a81e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,10 @@ tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1 # Derive macros for the crate-wide error type in `crates/tinysecurity-module/src/error/`. # Every dependency entry should carry a comment like this one saying why it is # here. +# OS entropy for opaque module-process scope references. +getrandom = "0.3" +# Conservative denial comparison on case-insensitive/normalizing filesystems. +unicode-normalization = "0.1" thiserror = "2" # The bus payload types are serialized into TinyBus frames. serde = { version = "1", features = ["derive"] } diff --git a/crates/tinysecurity-policy/Cargo.toml b/crates/tinysecurity-policy/Cargo.toml index c2ab908e..97e2adcd 100644 --- a/crates/tinysecurity-policy/Cargo.toml +++ b/crates/tinysecurity-policy/Cargo.toml @@ -13,6 +13,8 @@ publish = false [dependencies] tinysecurity-bus = { workspace = true } thiserror = { workspace = true } +getrandom = { workspace = true } +unicode-normalization = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/tinysecurity-policy/src/path_registry.rs b/crates/tinysecurity-policy/src/path_registry.rs index 28435655..d483b6c8 100644 --- a/crates/tinysecurity-policy/src/path_registry.rs +++ b/crates/tinysecurity-policy/src/path_registry.rs @@ -1,11 +1,12 @@ //! Bounded immutable scopes; registration never changes another agent's policy. use crate::PathScope; use std::sync::Arc; +const HEX: &[u8; 16] = b"0123456789abcdef"; use tinysecurity_bus::{Error, PathPolicy, PathPolicyId, Result}; /// Registry of immutable host-authorized scopes, preserved across SDK reinit. #[derive(Debug, Default)] pub struct PathPolicyRegistry { - scopes: Vec>, + scopes: Vec<(PathPolicyId, Arc)>, } impl PathPolicyRegistry { /// Register or retrieve an identical immutable policy. @@ -13,6 +14,15 @@ impl PathPolicyRegistry { /// # Errors /// Rejects malformed policy roots and a full registry (1024 distinct scopes). pub fn register(&mut self, policy: PathPolicy) -> Result { + self.register_with_entropy(policy, |bytes| { + getrandom::fill(bytes).map_err(|_| Error::Unavailable) + }) + } + fn register_with_entropy( + &mut self, + policy: PathPolicy, + mut entropy: impl FnMut(&mut [u8]) -> Result<()>, + ) -> Result { if !policy.workspace_dir.is_absolute() || !policy.action_dir.is_absolute() || policy @@ -38,42 +48,54 @@ impl PathPolicyRegistry { { return Err(Error::InvalidArguments); } - if let Some(index) = self.scopes.iter().position(|scope| scope.0 == policy) { - return Ok(PathPolicyId(format!("path-scope-{index}"))); + if let Some((id, _)) = self.scopes.iter().find(|(_, scope)| scope.0 == policy) { + return Ok(id.clone()); } if self.scopes.len() >= 1024 { return Err(Error::Unavailable); } - let index = self.scopes.len(); - self.scopes.push(Arc::new(PathScope(policy))); - Ok(PathPolicyId(format!("path-scope-{index}"))) + for _ in 0..4 { + let mut bytes = [0_u8; 32]; + entropy(&mut bytes)?; + let id = PathPolicyId( + bytes + .iter() + .flat_map(|byte| { + [ + char::from(HEX[usize::from(byte >> 4)]), + char::from(HEX[usize::from(byte & 15)]), + ] + }) + .collect(), + ); + if self.scopes.iter().any(|(existing, _)| *existing == id) { + continue; + } + self.scopes.push((id.clone(), Arc::new(PathScope(policy)))); + return Ok(id); + } + Err(Error::Unavailable) } /// Clone an immutable scope handle without cloning its configuration. /// /// # Errors /// Rejects unknown or malformed identifiers. pub fn get_owned(&self, id: &PathPolicyId) -> Result> { - let index = - id.0.strip_prefix("path-scope-") - .and_then(|index| index.parse::().ok()) - .ok_or(Error::InvalidArguments)?; self.scopes - .get(index) - .cloned() + .iter() + .find(|(registered, _)| registered == id) + .map(|(_, scope)| Arc::clone(scope)) .ok_or(Error::InvalidArguments) } /// Retrieve only a previously registered immutable scope. /// /// # Errors - /// Rejects unknown or malformed identifiers. + /// Rejects unknown identifiers; identifiers do not confer caller ownership. pub fn get(&self, id: &PathPolicyId) -> Result<&PathScope> { - let index = - id.0.strip_prefix("path-scope-") - .and_then(|index| index.parse::().ok()) - .ok_or(Error::InvalidArguments)?; self.scopes - .get(index) - .map(AsRef::as_ref) + .iter() + .find(|(registered, _)| registered == id) + .map(|(_, scope)| scope.as_ref()) .ok_or(Error::InvalidArguments) } } diff --git a/crates/tinysecurity-policy/src/path_registry_tests.rs b/crates/tinysecurity-policy/src/path_registry_tests.rs index 52d02c82..7b4b1991 100644 --- a/crates/tinysecurity-policy/src/path_registry_tests.rs +++ b/crates/tinysecurity-policy/src/path_registry_tests.rs @@ -121,3 +121,69 @@ fn each_registered_root_kind_must_be_native_absolute() } Ok(()) } +/// Scope references must not expose registry insertion offsets. +#[test] +fn generated_scope_ids_are_random_references_and_equal_policies_reuse_them() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let policy = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: root.path().into(), + action_dir: root.path().into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let mut first = PathPolicyRegistry::default(); + let mut second = PathPolicyRegistry::default(); + let id = first.register(policy.clone())?; + assert!(first.get(&PathPolicyId("path-scope-0".into())).is_err()); + assert_ne!(id, second.register(policy.clone())?); + assert_eq!(id, first.register(policy)?); + assert!(first.get(&id).is_ok()); + Ok(()) +} +/// Entropy failure and repeated collisions cannot mint predictable fallback IDs. +#[test] +fn scope_registration_fails_closed_on_entropy_failure_or_collisions() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: root.path().into(), + action_dir: root.path().into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let mut registry = PathPolicyRegistry::default(); + assert_eq!( + registry.register_with_entropy(policy.clone(), |_| Err(Error::Unavailable)), + Err(Error::Unavailable) + ); + let id = registry.register_with_entropy(policy.clone(), |bytes| { + bytes.fill(0); + Ok(()) + })?; + policy.reserved_names.push("different".into()); + assert_eq!( + registry.register_with_entropy(policy, |bytes| { + bytes.fill(0); + Ok(()) + }), + Err(Error::Unavailable) + ); + assert!(registry.get(&id).is_ok()); + assert!(registry.get_owned(&PathPolicyId("bogus".into())).is_err()); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index 043634b4..8c4a3e2c 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -83,7 +83,43 @@ fn canonical_or_original(path: &Path) -> PathBuf { resolve_ancestor(path).unwrap_or_else(|_| path.to_path_buf()) } fn within(path: &Path, root: &Path) -> bool { - path.starts_with(root) || path.starts_with(canonical_or_original(root)) + path.starts_with(canonical_or_original(root)) +} +// Denial comparisons fold conservatively on Windows/macOS, including normalizing +// filesystems. Grants and reservation exceptions remain canonical exact matches. +#[cfg(any(windows, target_os = "macos", test))] +fn denial_fold(value: &str) -> String { + use unicode_normalization::UnicodeNormalization; + value.nfc().collect::().to_uppercase() +} +fn deny_component_eq(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool { + #[cfg(any(windows, target_os = "macos"))] + { + denial_fold(&left.to_string_lossy()) == denial_fold(&right.to_string_lossy()) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + left == right + } +} +fn deny_starts_with(path: &Path, root: &Path) -> bool { + let mut candidate = path.components(); + root.components().all(|part| { + candidate + .next() + .is_some_and(|next| deny_component_eq(next.as_os_str(), part.as_os_str())) + }) +} +fn within_denied(path: &Path, root: &Path) -> bool { + deny_starts_with(path, root) || deny_starts_with(path, &canonical_or_original(root)) +} +fn candidate(path: &Path) -> Option { + if !path.is_absolute() || protected_path(path) { + return None; + } + resolve_ancestor(path) + .ok() + .filter(|resolved| !protected_path(resolved)) } /// Compiled immutable filesystem scope. #[derive(Clone, Debug)] @@ -100,22 +136,35 @@ impl PathScope { pub fn internal(&self, path: &Path) -> bool { let resolved = canonical_or_original(path); self.reserved_paths.iter().any(|reservation| { - within(&resolved, &reservation.path) - && (!reservation.children_only - || resolved != canonical_or_original(&reservation.path)) - && !reservation - .exceptions - .iter() - .any(|exception| within(&resolved, exception)) + if !within_denied(&resolved, &reservation.path) { + return false; + } + let root = canonical_or_original(&reservation.path); + if reservation.children_only + && deny_starts_with(&resolved, &root) + && resolved.components().count() == root.components().count() + { + return false; + } + !reservation + .exceptions + .iter() + .any(|exception| within(&resolved, exception)) }) || self.reserved_prefixes.iter().any(|reservation| { let root = canonical_or_original(&reservation.root); + if !deny_starts_with(&resolved, &root) { + return false; + } resolved - .strip_prefix(&root) - .ok() - .and_then(|relative| relative.components().next()) + .components() + .nth(root.components().count()) .is_some_and(|component| { let name = component.as_os_str().to_string_lossy(); - name.strip_prefix(&reservation.prefix) + #[cfg(any(windows, target_os = "macos"))] + let (name, prefix) = (denial_fold(&name), denial_fold(&reservation.prefix)); + #[cfg(not(any(windows, target_os = "macos")))] + let prefix = reservation.prefix.clone(); + name.strip_prefix(&prefix) .is_some_and(|suffix| !suffix.is_empty()) }) }) @@ -123,35 +172,46 @@ impl PathScope { /// Whether an explicitly trusted root grants this operation. #[must_use] pub fn trusted(&self, path: &Path, write: bool) -> bool { - !protected_path(path) - && self.trusted_roots.iter().any(|root| { - (!write || root.access == PathAccess::ReadWrite) && within(path, &root.path) - }) + let Some(resolved) = candidate(path) else { + return false; + }; + self.trusted_canonical(&resolved, write) + } + fn trusted_canonical(&self, path: &Path, write: bool) -> bool { + self.trusted_roots.iter().any(|root| { + (!write || root.access == PathAccess::ReadWrite) && within(path, &root.path) + }) } + /// Resolved-path containment check, independent of approval policy. #[must_use] pub fn resolved_allowed(&self, path: &Path, write: bool) -> bool { - if protected_path(path) { + let Some(resolved) = candidate(path) else { return false; - } + }; + let path = resolved.as_path(); if !self.enabled { return true; } if self.internal(path) - || (write && self.readonly_paths.iter().any(|root| within(path, root))) + || (write + && self + .readonly_paths + .iter() + .any(|root| within_denied(path, root))) { return false; } - within(path, &self.workspace_dir) || self.trusted(path, write) + within(path, &self.workspace_dir) || self.trusted_canonical(path, write) } - fn forbidden(&self, path: &Path) -> bool { - if !self.enabled || self.trusted(path, false) { + fn forbidden(&self, path: &Path, write: bool) -> bool { + if !self.enabled || self.trusted(path, write) { return false; } let workspace = canonical_or_original(&self.workspace_dir); self.forbidden_paths.iter().any(|entry| { let forbidden = expand(self, entry); - if forbidden.is_absolute() && workspace.starts_with(&forbidden) { + if forbidden.is_absolute() && deny_starts_with(&workspace, &forbidden) { return false; } let forbidden = if forbidden.is_absolute() { @@ -159,7 +219,7 @@ impl PathScope { } else { workspace.join(forbidden) }; - within(path, &forbidden) + within_denied(path, &forbidden) }) } fn absolute(&self, input: &str) -> PathBuf { @@ -182,7 +242,7 @@ impl PathScope { && self.reserved_names.iter().any(|name| { expanded .file_name() - .is_some_and(|file| file == std::ffi::OsStr::new(name)) + .is_some_and(|file| deny_component_eq(file, std::ffi::OsStr::new(name))) }) { return false; @@ -205,7 +265,7 @@ impl PathScope { && self .forbidden_paths .iter() - .any(|entry| expanded.starts_with(expand(self, entry))) + .any(|entry| deny_starts_with(&expanded, &expand(self, entry))) { return false; } @@ -219,7 +279,7 @@ impl PathScope { { return false; } - if !within(&resolved, &self.workspace_dir) && self.forbidden(&resolved) { + if self.forbidden(&resolved, false) { return false; } } @@ -259,7 +319,7 @@ impl PathScope { if protected_path(&resolved) { return Err(Rejection::Protected); } - if !self.resolved_allowed(&resolved, write) || self.forbidden(&resolved) { + if !self.resolved_allowed(&resolved, write) || self.forbidden(&resolved, write) { return Err(Rejection::PolicyDenied); } Ok(resolved) diff --git a/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs index 8378c9ac..c0587c96 100644 --- a/crates/tinysecurity-policy/src/path_scope_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -271,3 +271,159 @@ fn windows_administrative_shares_and_device_namespaces_never_hide_system_roots() ); } } +#[cfg(unix)] +#[test] +fn public_boolean_authorization_resolves_symlinks_before_containment() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let mut policy = scope(root.path()); + policy.0.trusted_roots.push(PathTrustedRoot { + path: root.path().into(), + access: PathAccess::ReadWrite, + }); + std::os::unix::fs::symlink(outside.path(), root.path().join("escape"))?; + for kind in [ + PathCheckKind::ResolvedRead, + PathCheckKind::ResolvedWrite, + PathCheckKind::TrustedRead, + PathCheckKind::TrustedWrite, + ] { + assert!( + !policy.check_path(&root.path().join("escape/new/file"), kind), + "unsafe result for {kind:?}" + ); + assert!(!policy.check_path(&root.path().join("../outside"), kind)); + } + std::os::unix::fs::symlink("/etc", root.path().join("system"))?; + policy.0.enabled = false; + assert!(!policy.check_path( + &root.path().join("system/passwd"), + PathCheckKind::ResolvedRead + )); + Ok(()) +} +#[test] +fn string_checks_deny_resolved_forbidden_subtrees_inside_workspace() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path()); + policy.0.workspace_only = false; + policy.0.forbidden_paths = vec!["secrets".into()]; + std::fs::create_dir(root.path().join("secrets"))?; + std::fs::write(root.path().join("secrets/file"), "x")?; + assert!(!policy.string_allowed(&root.path().join("secrets/file").to_string_lossy())); + #[cfg(unix)] + { + std::os::unix::fs::symlink(root.path().join("secrets"), root.path().join("alias"))?; + assert!(!policy.string_allowed("alias/file")); + } + Ok(()) +} +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn prospective_denial_roots_match_native_case_aliases() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path()); + policy.0.forbidden_paths = vec!["secrets".into()]; + policy.0.reserved_paths.push(PathReservation { + path: root.path().join("reserved"), + children_only: false, + exceptions: vec![], + }); + policy.0.readonly_paths.push(root.path().join("outputs")); + policy + .0 + .reserved_prefixes + .push(tinysecurity_bus::PathReservedPrefix { + root: root.path().into(), + prefix: "state-".into(), + }); + for input in [ + "Secrets/file", + "RESERVED/new/file", + "Outputs/file", + "STATE-alice/file", + ".ENV", + ] { + assert_eq!( + policy.validate(input, true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + }, + "case alias {input}" + ); + } + Ok(()) +} +/// Denial folding normalizes decomposed names and non-ASCII native case aliases. +#[test] +fn conservative_denial_fold_covers_unicode_case_and_normalization() { + assert_eq!(denial_fold("SeCrEtS"), denial_fold("secrets")); + assert_eq!(denial_fold("caf\u{e9}"), denial_fold("cafe\u{301}")); + assert_eq!(denial_fold("\u{131}nternal"), denial_fold("Internal")); +} +/// Trusted grants and exception roots must never use conservative denial folding. +#[test] +fn case_distinct_missing_grants_and_exceptions_are_not_broadened() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let mut policy = scope(root.path()); + policy.0.trusted_roots.push(PathTrustedRoot { + path: outside.path().join("Granted"), + access: PathAccess::ReadWrite, + }); + assert!(!policy.trusted(&outside.path().join("granted/file"), true)); + policy.0.reserved_paths.push(PathReservation { + path: root.path().join("state"), + children_only: false, + exceptions: vec![root.path().join("state/Readable")], + }); + assert!(policy.internal(&root.path().join("state/readable/new/file"))); + assert!(!policy.internal(&root.path().join("state/Readable/new/file"))); + Ok(()) +} +/// Read grants cannot remove a configured write restriction inside a writable workspace. +#[test] +fn readonly_trusted_root_overrides_forbidden_paths_only_for_reads() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path()); + policy.0.workspace_only = false; + policy.0.forbidden_paths = vec!["blocked".into()]; + policy.0.trusted_roots.push(PathTrustedRoot { + path: root.path().join("blocked"), + access: PathAccess::ReadOnly, + }); + std::fs::create_dir(root.path().join("blocked"))?; + let path = root.path().join("blocked/file"); + std::fs::write(&path, "x")?; + assert!(matches!( + policy.validate(&path.to_string_lossy(), false), + PathValidationResult::Allowed { .. } + )); + assert_eq!( + policy.validate(&path.to_string_lossy(), true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + Ok(()) +} +/// Acting-scope roots deliberately preserve the host's configurable installation paths. +#[test] +fn acting_scope_and_conservative_bootstrap_have_distinct_system_root_contracts() { + for root in [ + "/usr", "/bin", "/sbin", "/lib", "/lib64", "/library", "/dev", + ] { + assert!( + !protected_path(Path::new(root)), + "acting scopes leave {root} configurable" + ); + assert!( + crate::path::floor(root), + "bootstrap checks remain conservative for {root}" + ); + } +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index 0014f8cd..f180ec08 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -13,11 +13,15 @@ trusted roots, host-state reservations and read-only subtrees. Product-specific state directory names remain in host configuration. The engine never reads the process's home environment or imports a host crate. -The registry deduplicates equal configurations and accepts at most 1024 distinct +The registry assigns 256-bit OS-entropy references and deduplicates equal configurations and accepts at most 1024 distinct scopes per module process. Registration never replaces an existing scope. A full registry fails closed; SDK reinitialization preserves registered scopes. A new module process requires fresh registration. Unknown identities fail as invalid -arguments. Scope handles are cloned under the registry lock; filesystem work +arguments. Entropy failure or repeated identifier collisions also fail closed. +References prevent insertion-index enumeration as defense in depth; they are not +caller ownership or an authentication mechanism. Only trusted host bus adapters +may register or submit scopes, and registration itself does not authorize an +untrusted caller. Scope handles are cloned under the registry lock; filesystem work runs on blocking workers after releasing it, keeping the module runtime and unrelated agent requests responsive during a stalled filesystem operation. @@ -34,13 +38,21 @@ even with policy disabled. System paths include every Windows drive and native extended aliases, administrative UNC shares and GLOBALROOT device namespaces. Unlike the conservative bootstrap Check path contract, portable 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. Trusted grants override configurable forbidden roots; read-only +trusted grants. 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 workspace root. Reserved single-component filenames retain the host's lexical -reservation contract. Reservations may allow child exceptions and may reserve +reservation contract. Public resolved and trusted-root queries resolve the +candidate, including existing symlinks and missing descendants, before checking +containment and the protected floor. String checks apply resolved forbidden +roots within the workspace as well as outside it. Reservations may allow child exceptions and may reserve only children so an artifact container remains listable. Read-only subtrees -remain unwritable through a broad workspace grant. +remain unwritable through a broad workspace grant. Denial roots, reserved names +and prefixes compare case-insensitively on Windows and conservatively on macOS, +including missing components. The macOS comparison can deny an additional case +alias on a case-sensitive volume. Trusted grants and reservation exceptions use +exact canonical containment, so denial folding never broadens their authority. Authorization is distinct from approval. These methods neither prompt nor park a tool invocation. Their canonical-path results do not make later filesystem I/O From e236cd22e17421201f230254b27fa33838797d57 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:44:02 +0300 Subject: [PATCH 04/15] test(policy): use a native absolute outside-root fixture Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs index c0587c96..66180ea1 100644 --- a/crates/tinysecurity-policy/src/path_scope_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -196,7 +196,8 @@ fn home_expansion_prefix_reservations_and_boolean_queries() -> Result<(), Box Date: Sat, 10 Oct 2026 19:47:39 +0300 Subject: [PATCH 05/15] fix(policy): normalize reserved names and root relative path restrictions Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope.rs | 20 +++--- .../src/path_scope_tests.rs | 63 +++++++++++++++++++ docs/specs/immutable-path-scopes.md | 3 +- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index 8c4a3e2c..7a842a63 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -19,7 +19,11 @@ fn expand(scope: &PathPolicy, path: &str) -> PathBuf { }) .unwrap_or_else(|| PathBuf::from(path)) } -/// Unconditional filesystem correctness and credential/system floor. +/// Unconditional acting-scope correctness, credential and critical-system floor. +/// Installation roots (`/usr`, `/bin`, `/sbin`, `/lib`, `/lib64`, `/library`, `/dev`) +/// deliberately remain host-configurable forbidden paths, preserving the host's +/// explicit grants and disabled-policy contract. Bootstrap `Check` retains its +/// separate conservative floor; these are different authorization contracts. #[must_use] pub fn protected_path(path: &Path) -> bool { let normalized = path @@ -238,7 +242,11 @@ impl PathScope { return false; } if !expanded.is_absolute() - && expanded.components().count() == 1 + && expanded + .components() + .filter(|part| !matches!(part, std::path::Component::CurDir)) + .count() + == 1 && self.reserved_names.iter().any(|name| { expanded .file_name() @@ -261,14 +269,6 @@ impl PathScope { if self.workspace_only && expanded.is_absolute() && !trusted { return false; } - if !trusted - && self - .forbidden_paths - .iter() - .any(|entry| deny_starts_with(&expanded, &expand(self, entry))) - { - return false; - } if let Ok(resolved) = resolve_ancestor(&absolute) { if protected_path(&resolved) || self.internal(&resolved) { return false; diff --git a/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs index 66180ea1..b3276b82 100644 --- a/crates/tinysecurity-policy/src/path_scope_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -428,3 +428,66 @@ fn acting_scope_and_conservative_bootstrap_have_distinct_system_root_contracts() ); } } +/// Current-directory prefixes do not weaken reserved relative filenames. +#[test] +fn reserved_relative_filenames_ignore_leading_current_directory_components() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path()); + for enabled in [true, false] { + policy.0.enabled = enabled; + for input in [".env", "./.env", "././.env"] { + assert!( + !policy.string_allowed(input), + "reserved name allowed: {input}" + ); + assert_eq!( + policy.validate(input, true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + } + #[cfg(windows)] + { + for input in [r".\.env", r".\.\.env", r".\.ENV"] { + assert!(!policy.string_allowed(input)); + assert_eq!( + policy.validate(input, true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + } + } + } + Ok(()) +} +/// Relative requests and forbidden entries use different configured roots. +#[test] +fn relative_forbidden_roots_do_not_block_same_named_action_subdirectories() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path()); + let action = root.path().join("subproject"); + std::fs::create_dir(&action)?; + policy.0.action_dir = action; + policy.0.forbidden_paths = vec!["secrets".into()]; + assert!(policy.string_allowed("secrets/public.txt")); + assert!(matches!( + policy.validate("secrets/public.txt", true), + PathValidationResult::Allowed { .. } + )); + assert!(!policy.string_allowed(&root.path().join("secrets/private.txt").to_string_lossy())); + policy.0.workspace_only = false; + assert_eq!( + policy.validate( + &root.path().join("secrets/private.txt").to_string_lossy(), + true + ), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + Ok(()) +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index f180ec08..9917e4ff 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -43,7 +43,8 @@ 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 workspace root. Reserved single-component filenames retain the host's lexical -reservation contract. Public resolved and trusted-root queries resolve the +reservation contract, including equivalent leading current-directory components +such as `./.env`. Public resolved and trusted-root queries resolve the candidate, including existing symlinks and missing descendants, before checking containment and the protected floor. String checks apply resolved forbidden roots within the workspace as well as outside it. Reservations may allow child exceptions and may reserve From 2f0c51d11e66139252ce8be428cc6758f4ba8450 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 19:58:53 +0300 Subject: [PATCH 06/15] fix(policy): reject empty filesystem paths Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope.rs | 6 ++++++ .../src/path_scope_tests.rs | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index 7a842a63..dc1a62f3 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -237,6 +237,9 @@ impl PathScope { /// String and existing-ancestor policy checks; actual I/O must also use validation. #[must_use] pub fn string_allowed(&self, input: &str) -> bool { + if input.is_empty() { + return false; + } let expanded = expand(self, input); if protected_path(&expanded) { return false; @@ -295,6 +298,9 @@ impl PathScope { } } fn validate_inner(&self, input: &str, write: bool) -> std::result::Result { + if input.is_empty() { + return Err(Rejection::InvalidPath); + } if protected_path(&expand(self, input)) { return Err(Rejection::Protected); } diff --git a/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs index b3276b82..03623700 100644 --- a/crates/tinysecurity-policy/src/path_scope_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -1,5 +1,26 @@ use super::*; use tinysecurity_bus::{PathAccess, PathReservation, PathTrustedRoot}; + +#[test] +fn empty_validation_requests_never_authorize_the_action_directory() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path()); + for enabled in [true, false] { + policy.0.enabled = enabled; + for write in [true, false] { + assert_eq!( + policy.validate("", write), + PathValidationResult::Denied { + category: Rejection::InvalidPath + }, + ); + } + assert!(!policy.string_allowed("")); + } + Ok(()) +} + fn scope(root: &Path) -> PathScope { PathScope(PathPolicy { enabled: true, From f0b41b0f2682da409dcf5c7118e4310f0ffe6002 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 20:07:21 +0300 Subject: [PATCH 07/15] fix(policy): confine compiled scope construction to validated registry Co-authored-by: Medulla --- .../tinysecurity-policy/src/path_registry.rs | 9 +- crates/tinysecurity-policy/src/path_scope.rs | 21 ++++- .../src/path_scope_contract_tests.rs | 86 +++++++++++++++++++ docs/specs/immutable-path-scopes.md | 7 ++ 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 crates/tinysecurity-policy/src/path_scope_contract_tests.rs diff --git a/crates/tinysecurity-policy/src/path_registry.rs b/crates/tinysecurity-policy/src/path_registry.rs index d483b6c8..9576d3ac 100644 --- a/crates/tinysecurity-policy/src/path_registry.rs +++ b/crates/tinysecurity-policy/src/path_registry.rs @@ -48,7 +48,11 @@ impl PathPolicyRegistry { { return Err(Error::InvalidArguments); } - if let Some((id, _)) = self.scopes.iter().find(|(_, scope)| scope.0 == policy) { + if let Some((id, _)) = self + .scopes + .iter() + .find(|(_, scope)| scope.policy() == &policy) + { return Ok(id.clone()); } if self.scopes.len() >= 1024 { @@ -71,7 +75,8 @@ impl PathPolicyRegistry { if self.scopes.iter().any(|(existing, _)| *existing == id) { continue; } - self.scopes.push((id.clone(), Arc::new(PathScope(policy)))); + self.scopes + .push((id.clone(), Arc::new(PathScope::registered(policy)))); return Ok(id); } Err(Error::Unavailable) diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index dc1a62f3..15206b62 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -125,9 +125,14 @@ fn candidate(path: &Path) -> Option { .ok() .filter(|resolved| !protected_path(resolved)) } -/// Compiled immutable filesystem scope. +/// Compiled immutable filesystem scope issued by [`crate::PathPolicyRegistry`]. +/// Direct external construction is intentionally unavailable: +/// ```compile_fail +/// use tinysecurity_policy::{PathScope, PathPolicy}; +/// fn bypass_registration(policy: PathPolicy) -> PathScope { PathScope(policy) } +/// ``` #[derive(Clone, Debug)] -pub struct PathScope(pub PathPolicy); +pub struct PathScope(PathPolicy); impl std::ops::Deref for PathScope { type Target = PathPolicy; fn deref(&self) -> &Self::Target { @@ -135,6 +140,14 @@ impl std::ops::Deref for PathScope { } } impl PathScope { + /// Construct only after the registry has admitted every configured root. + pub(crate) fn registered(policy: PathPolicy) -> Self { + Self(policy) + } + pub(crate) fn policy(&self) -> &PathPolicy { + &self.0 + } + /// Whether the path is under a host state reservation, including aliased/missing descendants. #[must_use] pub fn internal(&self, path: &Path) -> bool { @@ -347,3 +360,7 @@ impl PathScope { #[cfg(test)] #[path = "path_scope_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "path_scope_contract_tests.rs"] +mod contract_tests; diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs new file mode 100644 index 00000000..b157f63d --- /dev/null +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -0,0 +1,86 @@ +use super::*; +fn scope(root: &Path) -> Result, tinysecurity_bus::Error> { + let mut registry = crate::PathPolicyRegistry::default(); + let policy = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: root.into(), + action_dir: root.into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let id = registry.register(policy)?; + registry.get_owned(&id) +} +/// Native critical-system targets fail before any filesystem read or write. +#[test] +fn platform_native_protected_target_is_denied_before_resolution() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + #[cfg(windows)] + let native = PathBuf::from( + std::env::var_os("SystemRoot") + .ok_or_else(|| std::io::Error::other("Windows SystemRoot is absent"))?, + ) + .join("System32"); + #[cfg(not(windows))] + let native = PathBuf::from("/etc"); + assert!(native.is_absolute()); + assert!(protected_path(&native)); + let scope = scope(root.path())?; + for write in [false, true] { + assert_eq!( + scope.validate(&native.to_string_lossy(), write), + PathValidationResult::Denied { + category: Rejection::Protected + } + ); + } + Ok(()) +} +/// Windows-looking namespace prefixes do not change native POSIX path meaning. +#[cfg(unix)] +#[test] +fn posix_question_mark_directory_is_not_a_windows_root_or_action_relative_path() { + use std::path::Component; + let path = Path::new("//?/etc/passwd"); + assert!(path.is_absolute()); + let components: Vec<_> = path.components().collect(); + assert_eq!( + components, + vec![ + Component::RootDir, + Component::Normal("?".as_ref()), + Component::Normal("etc".as_ref()), + Component::Normal("passwd".as_ref()) + ] + ); + // The normalized spelling is used for floor classification only. It is + // never used to construct the filesystem target passed to canonicalize. + assert!(!protected_path(path)); + let root = Path::new("/acting-root"); + let policy = PathPolicy { + enabled: false, + workspace_only: false, + workspace_dir: root.into(), + action_dir: root.into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let scope = PathScope::registered(policy); + assert_eq!( + scope.absolute("//?/etc/passwd"), + PathBuf::from("//?/etc/passwd") + ); + assert!(protected_path(Path::new("/etc/passwd"))); +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index 9917e4ff..ea66d6c9 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -62,6 +62,13 @@ atomic: a host still needs an execution sandbox to bound filesystem races. Acceptance coverage includes missing targets, aliased reserved state, disabled policy floors, home expansion, relative forbidden roots, independent registry identities, registry exhaustion, read-only grants and existing/broken symlinks. +The compiled scope constructor is private; production handles are issued only +after registry validation. A compile-fail API test pins that boundary. The +Windows alias normalization is a floor classifier, never a filesystem path +rewrite: on POSIX, `//?/etc/passwd` names the `?` directory beneath `/`, not +`/etc/passwd`, and is never rebased beneath the action directory. Validation +checks the original native path and repeats the floor on its resolved target. +Native critical-system fixtures supplement cross-platform Windows-spelling tests. The real native loader verifier calls every new member and verifies that a scope survives an accepted SDK reinitialization. This completes filesystem capability migration support; command policy, redaction, approvals, egress, audit and crypto From efca909b13180951df54b6d5c1ff26f614fedf8c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 20:12:41 +0300 Subject: [PATCH 08/15] fix(policy): fail closed on unresolved path authorization Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope.rs | 50 +++++---- .../src/path_scope_contract_tests.rs | 101 ++++++++++++++++++ docs/specs/immutable-path-scopes.md | 13 +++ 3 files changed, 146 insertions(+), 18 deletions(-) diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index 15206b62..6015060a 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -87,7 +87,7 @@ fn canonical_or_original(path: &Path) -> PathBuf { resolve_ancestor(path).unwrap_or_else(|_| path.to_path_buf()) } fn within(path: &Path, root: &Path) -> bool { - path.starts_with(canonical_or_original(root)) + resolve_ancestor(root).is_ok_and(|resolved| path.starts_with(resolved)) } // Denial comparisons fold conservatively on Windows/macOS, including normalizing // filesystems. Grants and reservation exceptions remain canonical exact matches. @@ -96,8 +96,17 @@ fn denial_fold(value: &str) -> String { use unicode_normalization::UnicodeNormalization; value.nfc().collect::().to_uppercase() } +#[cfg(any(windows, test))] +fn windows_denial_fold(value: &str) -> String { + denial_fold(value.trim_end_matches(['.', ' '])) +} fn deny_component_eq(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool { - #[cfg(any(windows, target_os = "macos"))] + #[cfg(windows)] + { + windows_denial_fold(&left.to_string_lossy()) + == windows_denial_fold(&right.to_string_lossy()) + } + #[cfg(target_os = "macos")] { denial_fold(&left.to_string_lossy()) == denial_fold(&right.to_string_lossy()) } @@ -276,7 +285,7 @@ impl PathScope { return false; } if !self.enabled { - return resolve_ancestor(&absolute).map_or(true, |resolved| !protected_path(&resolved)); + return resolve_ancestor(&absolute).is_ok_and(|resolved| !protected_path(&resolved)); } if self.internal(&absolute) { return false; @@ -285,19 +294,20 @@ impl PathScope { if self.workspace_only && expanded.is_absolute() && !trusted { return false; } - if let Ok(resolved) = resolve_ancestor(&absolute) { - if protected_path(&resolved) || self.internal(&resolved) { - return false; - } - if self.workspace_only - && !within(&resolved, &self.workspace_dir) - && !self.trusted(&resolved, false) - { - return false; - } - if self.forbidden(&resolved, false) { - return false; - } + let Ok(resolved) = resolve_ancestor(&absolute) else { + return false; + }; + if protected_path(&resolved) || self.internal(&resolved) { + return false; + } + if self.workspace_only + && !within(&resolved, &self.workspace_dir) + && !self.trusted(&resolved, false) + { + return false; + } + if self.forbidden(&resolved, false) { + return false; } true } @@ -311,14 +321,18 @@ impl PathScope { } } fn validate_inner(&self, input: &str, write: bool) -> std::result::Result { - if input.is_empty() { + if input.is_empty() || (write && expand(self, input).file_name().is_none()) { return Err(Rejection::InvalidPath); } if protected_path(&expand(self, input)) { return Err(Rejection::Protected); } if !self.string_allowed(input) { - return Err(Rejection::PolicyDenied); + return Err(if resolve_ancestor(&self.absolute(input)).is_err() { + Rejection::Resolution + } else { + Rejection::PolicyDenied + }); } let absolute = self.absolute(input); let resolved = if write { diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index b157f63d..95b81af9 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -84,3 +84,104 @@ fn posix_question_mark_directory_is_not_a_windows_root_or_action_relative_path() ); assert!(protected_path(Path::new("/etc/passwd"))); } + +#[cfg(unix)] +#[test] +fn string_checks_fail_closed_for_broken_symlinks_with_policy_on_or_off() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink(root.path().join("absent"), root.path().join("broken"))?; + for enabled in [false, true] { + let mut policy = scope(root.path())?.policy().clone(); + policy.enabled = enabled; + let scope = PathScope::registered(policy); + assert!(!scope.string_allowed("broken/child")); + assert!(!scope.string_allowed("broken")); + assert!(scope.string_allowed("ordinary-missing/child")); + } + Ok(()) +} + +#[test] +fn parent_validation_requires_filename_but_read_accepts_current_directory() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let scope = scope(root.path())?; + for path in [".", "./", "././"] { + assert_eq!( + scope.validate(path, true), + PathValidationResult::Denied { + category: Rejection::InvalidPath + } + ); + assert_eq!( + scope.validate(path, false), + PathValidationResult::Allowed { + resolved: root.path().canonicalize()? + } + ); + } + Ok(()) +} + +#[test] +fn windows_denial_component_aliases_trim_trailing_dots_and_spaces_only() { + assert_eq!( + windows_denial_fold("secrets. "), + windows_denial_fold("SECRETS") + ); + assert_ne!(windows_denial_fold("other"), windows_denial_fold("secrets")); +} + +#[cfg(windows)] +#[test] +fn prospective_windows_aliases_do_not_bypass_forbidden_roots() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path())?.policy().clone(); + policy.enabled = true; + policy.forbidden_paths = vec!["secrets".into()]; + let scope = PathScope::registered(policy); + for path in ["secrets.\\file", "secrets \\file", "secrets. \\file"] { + assert_eq!( + scope.validate(path, true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + } + Ok(()) +} + +#[cfg(unix)] +#[test] +fn unresolvable_grant_root_never_uses_lexical_containment() -> Result<(), Box> +{ + let root = tempfile::tempdir()?; + let broken = root.path().join("broken"); + std::os::unix::fs::symlink(root.path().join("absent"), &broken)?; + assert!(!within(&broken.join("child"), &broken)); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn missing_forbidden_roots_resolve_through_existing_symlink_ancestors() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + std::os::unix::fs::symlink(outside.path(), root.path().join("alias"))?; + let mut policy = scope(root.path())?.policy().clone(); + policy.enabled = true; + policy.forbidden_paths = vec![ + root.path() + .join("alias/missing") + .to_string_lossy() + .into_owned(), + ]; + let scope = PathScope::registered(policy); + let target = outside.path().join("missing/file"); + assert!(scope.forbidden(&target, true)); + assert!(!scope.string_allowed(&target.to_string_lossy())); + Ok(()) +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index ea66d6c9..42d0ce0a 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -73,3 +73,16 @@ The real native loader verifier calls every new member and verifies that a scope survives an accepted SDK reinitialization. This completes filesystem capability migration support; command policy, redaction, approvals, egress, audit and crypto remain separate capabilities. + +Boolean string queries fail closed if existing-ancestor resolution fails, even +when configurable policy is disabled. Prospective missing paths remain valid +when their existing ancestor resolves. Grant/exception comparisons never fall +back to an unresolvable lexical root. On Windows, denial comparisons also trim +ordinary Win32 trailing-dot/space component aliases; grants remain exact. +`ValidateParent` requires an input filename: current-directory-only inputs such +as `.` and `./` are invalid, while `ValidatePath` can read that directory. + +Authorization returns a resolved path, not an opened file or directory handle. +Filesystem changes after the response can race with the host's subsequent I/O. +Hosts requiring atomic containment must use directory handles/no-follow I/O or +an execution sandbox; this policy module does not perform the file operation. From bcb70272f82def7bcf5e785b379ba764c70b4415 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 20:17:40 +0300 Subject: [PATCH 09/15] fix(policy): reject ambiguous Windows roots and stream aliases Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope.rs | 37 +++++++- .../src/path_scope_contract_tests.rs | 85 +++++++++++++++++++ docs/specs/immutable-path-scopes.md | 6 ++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index 6015060a..ada68e73 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -98,7 +98,35 @@ fn denial_fold(value: &str) -> String { } #[cfg(any(windows, test))] fn windows_denial_fold(value: &str) -> String { - denial_fold(value.trim_end_matches(['.', ' '])) + // Prefix components are not filenames (C: and extended/UNC prefixes). + if (value.len() == 2 && value.as_bytes()[0].is_ascii_alphabetic() && value.ends_with(':')) + || value.starts_with('\\') + { + return denial_fold(value); + } + denial_fold( + value + .split(':') + .next() + .unwrap_or(value) + .trim_end_matches(['.', ' ']), + ) +} +#[cfg(any(windows, test))] +fn windows_ambiguous_relative(value: &str) -> bool { + value.starts_with(['/', '\\']) + || (value.as_bytes().get(1) == Some(&b':') && value.as_bytes()[0].is_ascii_alphabetic()) +} +fn ambiguous_native(path: &Path) -> bool { + #[cfg(windows)] + { + !path.is_absolute() && windows_ambiguous_relative(&path.to_string_lossy()) + } + #[cfg(not(windows))] + { + let _ = path; + false + } } fn deny_component_eq(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool { #[cfg(windows)] @@ -263,7 +291,7 @@ impl PathScope { return false; } let expanded = expand(self, input); - if protected_path(&expanded) { + if ambiguous_native(&expanded) || protected_path(&expanded) { return false; } if !expanded.is_absolute() @@ -321,7 +349,10 @@ impl PathScope { } } fn validate_inner(&self, input: &str, write: bool) -> std::result::Result { - if input.is_empty() || (write && expand(self, input).file_name().is_none()) { + if input.is_empty() + || ambiguous_native(&expand(self, input)) + || (write && expand(self, input).file_name().is_none()) + { return Err(Rejection::InvalidPath); } if protected_path(&expand(self, input)) { diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index 95b81af9..b6c6b5bb 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -185,3 +185,88 @@ fn missing_forbidden_roots_resolve_through_existing_symlink_ancestors() assert!(!scope.string_allowed(&target.to_string_lossy())); Ok(()) } + +#[test] +fn windows_stream_aliases_match_reserved_base_filename() { + assert_eq!( + windows_denial_fold(".env:payload"), + windows_denial_fold(".env") + ); + assert_eq!( + windows_denial_fold(".env. :payload"), + windows_denial_fold(".env") + ); +} + +#[test] +fn windows_nonabsolute_inputs_that_can_replace_join_base_are_ambiguous() { + for value in ["\\workspace\\secret", "/workspace/secret", "D:secret", "c:"] { + assert!(windows_ambiguous_relative(value)); + } + for value in ["secret", ".\\secret", "nested/secret"] { + assert!(!windows_ambiguous_relative(value)); + } +} + +#[cfg(windows)] +#[test] +fn windows_ambiguous_roots_and_reserved_streams_are_denied() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path())?.policy().clone(); + policy.enabled = true; + policy.workspace_only = true; + policy.action_dir = root.path().join("acting"); + std::fs::create_dir(&policy.action_dir)?; + policy.reserved_names = vec![".env".into()]; + policy.readonly_paths = vec![policy.action_dir.join("outputs")]; + let scope = PathScope::registered(policy); + for path in ["\\workspace\\secret", "D:secret"] { + assert!(!scope.string_allowed(path)); + assert_eq!( + scope.validate(path, true), + PathValidationResult::Denied { + category: Rejection::InvalidPath + } + ); + } + for path in [".env:payload", ".\\.env:payload", ".env. :payload"] { + assert!(!scope.string_allowed(path)); + // NTFS stream support depends on the fixture volume. Unsupported + // stream spellings can also fail filesystem resolution, still denied. + assert!(matches!( + scope.validate(path, true), + PathValidationResult::Denied { .. } + )); + } + assert!(matches!( + scope.validate("outputs. :payload", true), + PathValidationResult::Denied { .. } + )); + assert!(protected_path(Path::new(".ssh:payload"))); + assert!(protected_path(Path::new(".ssh. :payload"))); + Ok(()) +} + +#[test] +fn windows_denial_folding_preserves_drive_prefix_components() { + assert_eq!(windows_denial_fold("C:"), "C:"); + assert_ne!(windows_denial_fold("C:"), windows_denial_fold("C")); +} + +#[cfg(unix)] +#[test] +fn unix_colon_filename_is_not_a_windows_stream_alias() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path())?.policy().clone(); + policy.reserved_names = vec![".env".into()]; + let scope = PathScope::registered(policy); + assert!(scope.string_allowed(".env:payload")); + assert_eq!( + scope.validate(".env:payload", true), + PathValidationResult::Allowed { + resolved: root.path().canonicalize()?.join(".env:payload") + } + ); + Ok(()) +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index 42d0ce0a..a113a4a1 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -86,3 +86,9 @@ Authorization returns a resolved path, not an opened file or directory handle. Filesystem changes after the response can race with the host's subsequent I/O. Hosts requiring atomic containment must use directory handles/no-follow I/O or an execution sandbox; this policy module does not perform the file operation. + +Windows action-relative requests reject drive-relative or root-relative forms +before joining the action directory. Denial filename comparisons include NTFS +stream aliases (split the stream suffix, then trim dots/spaces), while drive and +UNC prefix components stay intact. Exact grants remain unchanged, and Unix +colon filenames do not acquire Windows stream semantics. From 5e90937e491039aaeae62fdb1297ac7f9670590b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 20:19:50 +0300 Subject: [PATCH 10/15] fix(policy): propagate prospective target inspection errors Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope.rs | 9 ++++++++- .../src/path_scope_contract_tests.rs | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index ada68e73..caffdb88 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -162,6 +162,13 @@ fn candidate(path: &Path) -> Option { .ok() .filter(|resolved| !protected_path(resolved)) } +fn metadata_present(result: std::io::Result<()>) -> std::result::Result { + match result { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(Rejection::Resolution), + } +} /// Compiled immutable filesystem scope issued by [`crate::PathPolicyRegistry`]. /// Direct external construction is intentionally unavailable: /// ```compile_fail @@ -370,7 +377,7 @@ impl PathScope { let parent = absolute.parent().ok_or(Rejection::InvalidPath)?; let filename = absolute.file_name().ok_or(Rejection::InvalidPath)?; // Existing target symlinks must also be checked: writing follows them. - if absolute.symlink_metadata().is_ok() { + if metadata_present(absolute.symlink_metadata().map(|_| ()))? { absolute.canonicalize().map_err(|_| Rejection::Resolution)? } else { resolve_ancestor(parent) diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index b6c6b5bb..8f40f196 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -270,3 +270,23 @@ fn unix_colon_filename_is_not_a_windows_stream_alias() -> Result<(), Box Date: Sat, 10 Oct 2026 21:20:28 +0300 Subject: [PATCH 11/15] fix(policy): contain reservation exceptions and repair native fixtures Co-authored-by: Medulla --- .github/workflows/ci.yml | 11 +++ crates/tinysecurity-bus/src/path_policy.rs | 7 +- .../examples/verify_module.rs | 55 +++++++++--- crates/tinysecurity-module/src/adapter.rs | 2 + .../tinysecurity-module/src/adapter_tests.rs | 8 +- .../tinysecurity-policy/src/path_registry.rs | 17 ++-- .../src/path_registry_tests.rs | 84 +++++++++++++++++++ crates/tinysecurity-policy/src/path_scope.rs | 18 ++-- .../src/path_scope_contract_tests.rs | 36 +++++++- .../src/path_scope_tests.rs | 49 ++++++----- docs/specs/immutable-path-scopes.md | 26 +++++- 11 files changed, 262 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e51b0be..1921119f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: macOS) module=target/debug/libtinysecurity_module.dylib ;; esac cargo run -p tinysecurity-module --example verify_module -- "$module" + # Installed examples must also work outside the workspace root. + module="$(pwd)/$module" + (cd target && ./debug/examples/verify_module "$module") - name: Verify Windows module through the restricted native copy if: ${{ runner.os == 'Windows' }} @@ -111,6 +114,14 @@ jobs: $verifiedModule = Join-Path $verifyRoot "$libraryName.dll" Copy-Item -LiteralPath $module -Destination $verifiedModule cargo run --locked --package tinysecurity-module --example verify_module -- $verifiedModule + $verifier = (Resolve-Path 'target/debug/examples/verify_module.exe').Path + Push-Location $env:RUNNER_TEMP + try { + & $verifier $verifiedModule + if ($LASTEXITCODE -ne 0) { throw 'native verifier failed outside workspace' } + } finally { + Pop-Location + } # `crates/tinysecurity-bus` exists so a host can name the payload types # without compiling the module. That promise is invisible in a diff, diff --git a/crates/tinysecurity-bus/src/path_policy.rs b/crates/tinysecurity-bus/src/path_policy.rs index 016a7e20..ff469952 100644 --- a/crates/tinysecurity-bus/src/path_policy.rs +++ b/crates/tinysecurity-bus/src/path_policy.rs @@ -19,7 +19,7 @@ pub struct PathTrustedRoot { /// Allowed operations. pub access: PathAccess, } -/// A reserved subtree, with explicitly readable child exceptions. +/// A reserved subtree, with explicit child exceptions subject to other policy checks. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PathReservation { @@ -28,7 +28,8 @@ pub struct PathReservation { /// Reserve descendants while allowing the container directory itself. #[serde(default)] pub children_only: bool, - /// Subtrees excluded from this reservation. + /// Subtrees excluded from this reservation. Each must resolve to a strict + /// descendant of the reserved path at registration and evaluation. pub exceptions: Vec, } /// A host-owned state directory family matched by its immediate child's prefix. @@ -102,7 +103,7 @@ pub enum PathValidationResult { /// Resolved path. resolved: PathBuf, }, - /// Rejected before filesystem I/O. + /// Rejected before the requested operation; validation may inspect filesystem metadata. Denied { /// Stable rejection category. category: PathErrorCategory, diff --git a/crates/tinysecurity-module/examples/verify_module.rs b/crates/tinysecurity-module/examples/verify_module.rs index 36119c09..0a356579 100644 --- a/crates/tinysecurity-module/examples/verify_module.rs +++ b/crates/tinysecurity-module/examples/verify_module.rs @@ -64,7 +64,7 @@ async fn main() -> std::result::Result<(), Box> { checks: vec![ Check::LocalDiagnostic {}, Check::Path { - path: "/etc/passwd".into(), + path: native_protected_path().to_string_lossy().into_owned(), write: false, }, ], @@ -89,9 +89,9 @@ async fn main() -> std::result::Result<(), Box> { .await .is_err() ); - let path_scope = register_native_scope(&proxy).await?; + let (path_scope, fixture) = register_native_scope(&proxy).await?; verify_reconfiguration(&host, &proxy, &info.name).await?; - verify_native_scope(&proxy, path_scope).await?; + verify_native_scope(&proxy, path_scope, fixture.path()).await?; let evaluate = EvaluateRequest { caller, call: ToolCall { @@ -202,8 +202,13 @@ async fn verify_reconfiguration( async fn register_native_scope( proxy: &tinybus::Proxy, -) -> std::result::Result> { - let root = std::env::current_dir()?.canonicalize()?; +) -> std::result::Result< + (tinysecurity_bus::PathPolicyId, tempfile::TempDir), + Box, +> { + let fixture = tempfile::tempdir()?; + std::fs::write(fixture.path().join("file"), "native scope fixture")?; + let root = fixture.path().canonicalize()?; let policy = tinysecurity_bus::PathPolicy { enabled: true, workspace_only: true, @@ -217,41 +222,53 @@ async fn register_native_scope( readonly_paths: vec![], reserved_names: vec![], }; - Ok(proxy + let id = proxy .call(names::methods::REGISTER_PATH_POLICY, (policy,)) - .await?) + .await?; + Ok((id, fixture)) } async fn verify_native_scope( proxy: &tinybus::Proxy, policy_id: tinysecurity_bus::PathPolicyId, + root: &std::path::Path, ) -> std::result::Result<(), Box> { use tinysecurity_bus::{ PathCheckKind, PathCheckRequest, PathValidationRequest, PathValidationResult, }; let request = PathValidationRequest { policy_id: policy_id.clone(), - path: "Cargo.toml".into(), + path: "file".into(), }; let read: PathValidationResult = proxy .call(names::methods::VALIDATE_PATH, (request,)) .await?; - assert!(matches!(read, PathValidationResult::Allowed { .. })); + assert_eq!( + read, + PathValidationResult::Allowed { + resolved: root.canonicalize()?.join("file") + } + ); let write: PathValidationResult = proxy .call( names::methods::VALIDATE_PARENT, (PathValidationRequest { policy_id: policy_id.clone(), - path: "target/prospective/path/file".into(), + path: "prospective/path/file".into(), },), ) .await?; - assert!(matches!(write, PathValidationResult::Allowed { .. })); + assert_eq!( + write, + PathValidationResult::Allowed { + resolved: root.canonicalize()?.join("prospective/path/file") + } + ); let protected: bool = proxy .call( names::methods::CHECK_PATH, (PathCheckRequest { policy_id, - path: "/etc/passwd".into(), + path: native_protected_path(), kind: PathCheckKind::Protected, },), ) @@ -260,3 +277,17 @@ async fn verify_native_scope( println!("native immutable path scope survives accepted SDK reinitialization"); Ok(()) } + +fn native_protected_path() -> PathBuf { + #[cfg(windows)] + { + std::env::var_os("SystemRoot") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) + .join("System32") + } + #[cfg(not(windows))] + { + PathBuf::from("/etc/passwd") + } +} diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 720bcdd1..45c5ee05 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -57,6 +57,8 @@ impl PolicyService { .await .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? } + // Host-only configuration on a private, trusted in-process bus. This module + // evaluates policy; it is not a public authentication or filesystem service. async fn register_path_policy( &self, policy: tinysecurity_bus::PathPolicy, diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index 03db18f9..61b13e24 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -1,5 +1,6 @@ //! Contract dispatch and malformed request tests through real in-memory `TinyBus`. use super::*; +use std::path::PathBuf; use tinybus::{Interface, broker::Broker, transport::memory::MemoryBus}; use tinysecurity_bus::*; #[test] @@ -188,7 +189,12 @@ async fn verify_scope_dispatch( names::methods::CHECK_PATH, (PathCheckRequest { policy_id: id.clone(), - path: "D:\\Windows\\file".into(), + path: if cfg!(windows) { + PathBuf::from(std::env::var_os("SystemRoot").ok_or("SystemRoot absent")?) + .join("System32") + } else { + PathBuf::from("/etc/passwd") + }, kind: PathCheckKind::Protected, },), ) diff --git a/crates/tinysecurity-policy/src/path_registry.rs b/crates/tinysecurity-policy/src/path_registry.rs index 9576d3ac..33eaf9ef 100644 --- a/crates/tinysecurity-policy/src/path_registry.rs +++ b/crates/tinysecurity-policy/src/path_registry.rs @@ -4,6 +4,10 @@ use std::sync::Arc; const HEX: &[u8; 16] = b"0123456789abcdef"; use tinysecurity_bus::{Error, PathPolicy, PathPolicyId, Result}; /// Registry of immutable host-authorized scopes, preserved across SDK reinit. +/// +/// This in-process library does not authenticate its Rust caller or confer OS +/// access. The host must keep registration and scope references behind its own +/// authenticated dispatch and private module bus. #[derive(Debug, Default)] pub struct PathPolicyRegistry { scopes: Vec<(PathPolicyId, Arc)>, @@ -12,7 +16,9 @@ impl PathPolicyRegistry { /// Register or retrieve an identical immutable policy. /// /// # Errors - /// Rejects malformed policy roots and a full registry (1024 distinct scopes). + /// Rejects malformed roots, reservation exceptions that cannot resolve to + /// strict descendants, a full registry (1024 distinct scopes), entropy + /// failure, or repeated identifier collisions. pub fn register(&mut self, policy: PathPolicy) -> Result { self.register_with_entropy(policy, |bytes| { getrandom::fill(bytes).map_err(|_| Error::Unavailable) @@ -31,10 +37,11 @@ impl PathPolicyRegistry { .any(|root| !root.path.is_absolute()) || policy.reserved_paths.iter().any(|root| { !root.path.is_absolute() - || root - .exceptions - .iter() - .any(|path| !path.is_absolute() || !path.starts_with(&root.path)) + || root.exceptions.iter().any(|path| { + !path.is_absolute() + || crate::path_scope::reservation_exception_root(&root.path, path) + .is_none() + }) }) || policy .reserved_prefixes diff --git a/crates/tinysecurity-policy/src/path_registry_tests.rs b/crates/tinysecurity-policy/src/path_registry_tests.rs index 7b4b1991..bb6ba353 100644 --- a/crates/tinysecurity-policy/src/path_registry_tests.rs +++ b/crates/tinysecurity-policy/src/path_registry_tests.rs @@ -187,3 +187,87 @@ fn scope_registration_fails_closed_on_entropy_failure_or_collisions() assert!(registry.get_owned(&PathPolicyId("bogus".into())).is_err()); Ok(()) } + +#[test] +fn reservation_exceptions_cannot_resolve_to_the_reserved_root_or_its_ancestor() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let state = root.path().join("state"); + std::fs::create_dir(&state)?; + let base = PathPolicy { + enabled: true, + workspace_only: true, + workspace_dir: root.path().into(), + action_dir: root.path().into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + for exception in [state.clone(), state.join(".."), state.join("child/..")] { + let mut policy = base.clone(); + policy + .reserved_paths + .push(tinysecurity_bus::PathReservation { + path: state.clone(), + children_only: false, + exceptions: vec![exception], + }); + assert_eq!( + PathPolicyRegistry::default().register(policy), + Err(Error::InvalidArguments) + ); + } + let mut policy = base; + policy + .reserved_paths + .push(tinysecurity_bus::PathReservation { + path: state.clone(), + children_only: false, + exceptions: vec![state.join("allowed/missing")], + }); + assert!(PathPolicyRegistry::default().register(policy).is_ok()); + Ok(()) +} +#[cfg(unix)] +#[test] +fn reservation_exception_symlinks_cannot_escape_at_registration_or_afterward() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let state = root.path().join("state"); + let child = state.join("child"); + std::fs::create_dir_all(&child)?; + let exception = state.join("exception"); + std::os::unix::fs::symlink(&child, &exception)?; + let policy = PathPolicy { + enabled: true, + workspace_only: true, + workspace_dir: root.path().into(), + action_dir: root.path().into(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![], + reserved_paths: vec![tinysecurity_bus::PathReservation { + path: state.clone(), + children_only: false, + exceptions: vec![exception.clone()], + }], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let mut registry = PathPolicyRegistry::default(); + let id = registry.register(policy.clone())?; + assert!(!registry.get(&id)?.internal(&child.join("file"))); + std::fs::remove_file(&exception)?; + std::os::unix::fs::symlink(root.path(), &exception)?; + assert!(registry.get(&id)?.internal(&state.join("private"))); + assert_eq!( + PathPolicyRegistry::default().register(policy), + Err(Error::InvalidArguments) + ); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index caffdb88..a0deae01 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -83,6 +83,11 @@ pub fn resolve_ancestor(path: &Path) -> std::io::Result { } } } +pub(crate) fn reservation_exception_root(root: &Path, exception: &Path) -> Option { + let root = resolve_ancestor(root).ok()?; + let exception = resolve_ancestor(exception).ok()?; + (exception != root && exception.starts_with(&root)).then_some(exception) +} fn canonical_or_original(path: &Path) -> PathBuf { resolve_ancestor(path).unwrap_or_else(|_| path.to_path_buf()) } @@ -207,10 +212,10 @@ impl PathScope { { return false; } - !reservation - .exceptions - .iter() - .any(|exception| within(&resolved, exception)) + !reservation.exceptions.iter().any(|exception| { + reservation_exception_root(&reservation.path, exception) + .is_some_and(|exception| resolved.starts_with(exception)) + }) }) || self.reserved_prefixes.iter().any(|reservation| { let root = canonical_or_original(&reservation.root); if !deny_starts_with(&resolved, &root) { @@ -358,7 +363,10 @@ impl PathScope { fn validate_inner(&self, input: &str, write: bool) -> std::result::Result { if input.is_empty() || ambiguous_native(&expand(self, input)) - || (write && expand(self, input).file_name().is_none()) + || (write + && Path::new(input.strip_prefix("~/").unwrap_or(input)) + .file_name() + .is_none()) { return Err(Rejection::InvalidPath); } diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index 8f40f196..0d51fe9b 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -180,8 +180,16 @@ fn missing_forbidden_roots_resolve_through_existing_symlink_ancestors() .into_owned(), ]; let scope = PathScope::registered(policy); - let target = outside.path().join("missing/file"); + let input = outside.path().join("missing/file"); + let target = resolve_ancestor(&input)?; assert!(scope.forbidden(&target, true)); + assert!(!scope.string_allowed(&input.to_string_lossy())); + assert_eq!( + scope.validate(&input.to_string_lossy(), true), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); assert!(!scope.string_allowed(&target.to_string_lossy())); Ok(()) } @@ -290,3 +298,29 @@ fn prospective_targets_require_not_found_rather_than_uninspectable_metadata() { ); } } + +#[test] +fn tilde_directory_reads_do_not_authorize_filename_less_parent_writes() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let mut policy = scope(root.path())?.policy().clone(); + policy.home_dir = Some(root.path().into()); + let scope = PathScope::registered(policy); + for input in ["~/", "~//", "~/.", "~/./"] { + assert!(matches!( + scope.validate(input, false), + PathValidationResult::Allowed { .. } + )); + assert_eq!( + scope.validate(input, true), + PathValidationResult::Denied { + category: Rejection::InvalidPath + } + ); + } + assert!(matches!( + scope.validate("~/new-file", true), + PathValidationResult::Allowed { .. } + )); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs index 03623700..fca7f1b3 100644 --- a/crates/tinysecurity-policy/src/path_scope_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -5,7 +5,7 @@ use tinysecurity_bus::{PathAccess, PathReservation, PathTrustedRoot}; fn empty_validation_requests_never_authorize_the_action_directory() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; for enabled in [true, false] { policy.0.enabled = enabled; for write in [true, false] { @@ -21,8 +21,9 @@ fn empty_validation_requests_never_authorize_the_action_directory() Ok(()) } -fn scope(root: &Path) -> PathScope { - PathScope(PathPolicy { +fn scope(root: &Path) -> tinysecurity_bus::Result { + let mut registry = crate::PathPolicyRegistry::default(); + let id = registry.register(PathPolicy { enabled: true, workspace_only: true, workspace_dir: root.into(), @@ -34,12 +35,13 @@ fn scope(root: &Path) -> PathScope { reserved_prefixes: vec![], readonly_paths: vec![], reserved_names: vec![".env".into()], - }) + })?; + Ok(registry.get(&id)?.clone()) } #[test] fn prospective_write_resolves_missing_descendants() -> Result<(), Box> { let root = tempfile::tempdir()?; - let scope = scope(root.path()); + let scope = scope(root.path())?; assert!(matches!( scope.validate("new/deep/file.txt", true), PathValidationResult::Allowed { .. } @@ -56,7 +58,7 @@ fn prospective_write_resolves_missing_descendants() -> Result<(), Box Result<(), Box> { let root = tempfile::tempdir()?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; let artifacts = root.path().join("artifacts"); let results = artifacts.join("tool-results"); std::fs::create_dir_all(&results)?; @@ -89,7 +91,7 @@ fn reserved_paths_override_grants_but_allow_readonly_exceptions() fn disabled_scope_keeps_floor_and_reserved_single_filenames() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; scope.0.enabled = false; assert!(!scope.string_allowed("../outside")); assert!(!scope.string_allowed(".ssh/key")); @@ -105,7 +107,7 @@ fn symlink_to_reserved_state_is_denied_even_with_missing_suffix() let internal = root.path().join("state"); std::fs::create_dir(&internal)?; std::os::unix::fs::symlink(&internal, root.path().join("alias"))?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; scope.0.reserved_paths.push(PathReservation { path: internal, children_only: false, @@ -124,7 +126,7 @@ fn read_only_grant_never_authorizes_a_write_outside_workspace() -> Result<(), Box> { let root = tempfile::tempdir()?; let outside = tempfile::tempdir()?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; scope.0.trusted_roots.push(PathTrustedRoot { path: outside.path().into(), access: PathAccess::ReadOnly, @@ -147,7 +149,7 @@ fn immutable_registration_deduplicates_without_changing_other_agents() -> Result<(), Box> { let root = tempfile::tempdir()?; let mut registry = crate::PathPolicyRegistry::default(); - let first = scope(root.path()).0; + let first = scope(root.path())?.0; let first_id = registry.register(first.clone())?; assert_eq!(first_id, registry.register(first.clone())?); let mut second = first; @@ -162,7 +164,7 @@ fn immutable_registration_deduplicates_without_changing_other_agents() fn absolute_requests_and_relative_forbidden_roots_preserve_policy_scope() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; std::fs::create_dir(root.path().join("blocked"))?; std::fs::write(root.path().join("blocked/file"), "x")?; scope.0.forbidden_paths = vec!["blocked".into()]; @@ -190,7 +192,7 @@ fn absolute_requests_and_relative_forbidden_roots_preserve_policy_scope() fn home_expansion_prefix_reservations_and_boolean_queries() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; scope.0.trusted_roots.push(PathTrustedRoot { path: root.path().into(), access: PathAccess::ReadWrite, @@ -215,7 +217,12 @@ fn home_expansion_prefix_reservations_and_boolean_queries() -> Result<(), Box Result<(), Box> { let root = tempfile::tempdir()?; let outside = tempfile::tempdir()?; - let mut scope = scope(root.path()); + let mut scope = scope(root.path())?; scope.0.trusted_roots.push(PathTrustedRoot { path: outside.path().into(), access: PathAccess::ReadOnly, @@ -299,7 +306,7 @@ fn public_boolean_authorization_resolves_symlinks_before_containment() -> Result<(), Box> { let root = tempfile::tempdir()?; let outside = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; policy.0.trusted_roots.push(PathTrustedRoot { path: root.path().into(), access: PathAccess::ReadWrite, @@ -329,7 +336,7 @@ fn public_boolean_authorization_resolves_symlinks_before_containment() fn string_checks_deny_resolved_forbidden_subtrees_inside_workspace() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; policy.0.workspace_only = false; policy.0.forbidden_paths = vec!["secrets".into()]; std::fs::create_dir(root.path().join("secrets"))?; @@ -346,7 +353,7 @@ fn string_checks_deny_resolved_forbidden_subtrees_inside_workspace() #[test] fn prospective_denial_roots_match_native_case_aliases() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; policy.0.forbidden_paths = vec!["secrets".into()]; policy.0.reserved_paths.push(PathReservation { path: root.path().join("reserved"), @@ -391,7 +398,7 @@ fn case_distinct_missing_grants_and_exceptions_are_not_broadened() -> Result<(), Box> { let root = tempfile::tempdir()?; let outside = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; policy.0.trusted_roots.push(PathTrustedRoot { path: outside.path().join("Granted"), access: PathAccess::ReadWrite, @@ -411,7 +418,7 @@ fn case_distinct_missing_grants_and_exceptions_are_not_broadened() fn readonly_trusted_root_overrides_forbidden_paths_only_for_reads() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; policy.0.workspace_only = false; policy.0.forbidden_paths = vec!["blocked".into()]; policy.0.trusted_roots.push(PathTrustedRoot { @@ -454,7 +461,7 @@ fn acting_scope_and_conservative_bootstrap_have_distinct_system_root_contracts() fn reserved_relative_filenames_ignore_leading_current_directory_components() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; for enabled in [true, false] { policy.0.enabled = enabled; for input in [".env", "./.env", "././.env"] { @@ -489,7 +496,7 @@ fn reserved_relative_filenames_ignore_leading_current_directory_components() fn relative_forbidden_roots_do_not_block_same_named_action_subdirectories() -> Result<(), Box> { let root = tempfile::tempdir()?; - let mut policy = scope(root.path()); + let mut policy = scope(root.path())?; let action = root.path().join("subproject"); std::fs::create_dir(&action)?; policy.0.action_dir = action; diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index a113a4a1..49826ee2 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -19,9 +19,21 @@ registry fails closed; SDK reinitialization preserves registered scopes. A new module process requires fresh registration. Unknown identities fail as invalid arguments. Entropy failure or repeated identifier collisions also fail closed. References prevent insertion-index enumeration as defense in depth; they are not -caller ownership or an authentication mechanism. Only trusted host bus adapters -may register or submit scopes, and registration itself does not authorize an -untrusted caller. Scope handles are cloned under the registry lock; filesystem work +caller ownership or an authentication mechanism. The host is responsible for restricting this interface to its private, +trusted in-process module bus; the service does not authenticate bus callers. +Do not expose it through a network listener or an agent-controlled generic bus +forwarder. First-party native modules share the host's address space and OS +privileges; they are not a sandbox boundary. A hostile native module can already +perform filesystem I/O without this policy engine. Untrusted code belongs in a +separate execution sandbox, without a connection to the policy bus. + +A caller-authored scope cannot change another scope or the host's selected +scope: registrations are immutable, and the host adapter derives the policy and +keeps its returned reference private. Model arguments contain operation paths, +never registration payloads or policy references. Registries constructed through +the Rust library likewise operate inside the caller's process and grant no new +OS authority. Identity authentication and bus admission belong to the host, +not to this portable policy evaluator. Scope handles are cloned under the registry lock; filesystem work runs on blocking workers after releasing it, keeping the module runtime and unrelated agent requests responsive during a stalled filesystem operation. @@ -92,3 +104,11 @@ before joining the action directory. Denial filename comparisons include NTFS stream aliases (split the stream suffix, then trim dots/spaces), while drive and UNC prefix components stay intact. Exact grants remain unchanged, and Unix colon filenames do not acquire Windows stream semantics. + +Reservation exceptions must resolve to strict descendants of their reservation +at registration and at evaluation. A parent traversal or a symlink to the +reservation's ancestor cannot exempt the whole reservation. Prospective child +exceptions remain supported through their deepest existing ancestor. +Home-directory requests using `~/` or equivalent forms such as `~/./` can be +read, but cannot supply a filename for `ValidateParent`. Home expansion applies +only to the leading `~/` form; a bare `~` is an ordinary relative filename. From f9b5bdc8a3653013fb4857d8a7c0ba540a37f8f2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:24:25 +0300 Subject: [PATCH 12/15] fix(example): satisfy Windows option mapping lint Co-authored-by: Medulla --- crates/tinysecurity-module/examples/verify_module.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinysecurity-module/examples/verify_module.rs b/crates/tinysecurity-module/examples/verify_module.rs index 0a356579..6265a712 100644 --- a/crates/tinysecurity-module/examples/verify_module.rs +++ b/crates/tinysecurity-module/examples/verify_module.rs @@ -282,8 +282,7 @@ fn native_protected_path() -> PathBuf { #[cfg(windows)] { std::env::var_os("SystemRoot") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\Windows")) + .map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from) .join("System32") } #[cfg(not(windows))] From 8c5ea6547b0c98116d89f1fd73a98188cadc3e33 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:31:41 +0300 Subject: [PATCH 13/15] fix(policy): isolate registration I/O and preserve protected denials Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 35 ++++++-- .../tinysecurity-module/src/adapter_tests.rs | 63 +++++++++++++++ crates/tinysecurity-policy/src/lib.rs | 2 +- .../tinysecurity-policy/src/path_registry.rs | 79 +++++++++++++------ crates/tinysecurity-policy/src/path_scope.rs | 8 +- .../src/path_scope_contract_tests.rs | 50 ++++++++++++ docs/specs/immutable-path-scopes.md | 8 +- 7 files changed, 208 insertions(+), 37 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 45c5ee05..45ba0f75 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -35,6 +35,32 @@ impl PolicyService { .map_err(|_| failure(&tinysecurity_bus::Error::Faulted)) } + async fn register_path_with( + &self, + policy: tinysecurity_bus::PathPolicy, + prepare: F, + ) -> BusResult + where + F: FnOnce( + tinysecurity_bus::PathPolicy, + ) -> tinysecurity_bus::Result + + Send + + 'static, + { + let paths = Arc::clone(&self.paths); + tokio::task::spawn_blocking(move || { + let prepared = prepare(policy).map_err(|error| failure(&error))?; + let mut registry = paths + .lock() + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))?; + registry + .register_prepared(prepared) + .map_err(|error| failure(&error)) + }) + .await + .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? + } + fn read(&self) -> BusResult> { self.policy .read() @@ -63,13 +89,8 @@ impl PolicyService { &self, policy: tinysecurity_bus::PathPolicy, ) -> BusResult { - let result = self - .paths - .lock() - .map_err(|_| failure(&tinysecurity_bus::Error::Faulted))? - .register(policy) - .map_err(|error| failure(&error)); - std::future::ready(result).await + self.register_path_with(policy, tinysecurity::PreparedPathPolicy::new) + .await } async fn validate_path( &self, diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index 61b13e24..fe9333e2 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -288,3 +288,66 @@ async fn blocked_filesystem_query_does_not_block_policy_info_or_another_scope() assert!(matches!(result, PathValidationResult::Allowed { .. })); Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn blocked_registration_does_not_block_policy_info_or_another_scope() +-> std::result::Result<(), Box> { + let root = tempfile::tempdir()?; + let service = PolicyService { + policy: Arc::new(RwLock::new(Policy::new(ModuleConfig::default())?)), + paths: Arc::new(Mutex::new(PathPolicyRegistry::default())), + }; + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let server = Connection::connect(bus.connect().await?).await?; + server + .serve_at(names::OBJECT_PATH.try_into()?, service.clone()) + .await?; + server.request_name(names::INTERFACE).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?; + let mut second = fixture_policy(root.path()); + second.enabled = false; + let second = service.register_path_policy(second).await?; + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocked = service.clone(); + let registration = fixture_policy(root.path()); + let pending = tokio::spawn(async move { + blocked + .register_path_with(registration, move |policy| { + let _ = started_tx.send(()); + let _ = release_rx.recv(); + tinysecurity::PreparedPathPolicy::new(policy) + }) + .await + }); + started_rx.await?; + if service.paths.try_lock().is_err() { + release_tx.send(())?; + let _ = pending.await?; + return Err(std::io::Error::other("registration retained the registry lock").into()); + } + // Release regardless of assertions so a failing test cannot strand a worker. + let responses = tokio::time::timeout(std::time::Duration::from_secs(2), async { + let info: PolicyInfo = proxy.call(names::methods::POLICY_INFO, ()).await?; + let result: PathValidationResult = proxy + .call( + names::methods::VALIDATE_PARENT, + (PathValidationRequest { + policy_id: second, + path: "new/file".into(), + },), + ) + .await?; + tinybus::Result::Ok((info, result)) + }) + .await; + release_tx.send(())?; + let _registered = pending.await??; + broker.abort(); + let (info, result) = responses??; + assert_eq!(info.generation, 1); + assert!(matches!(result, PathValidationResult::Allowed { .. })); + Ok(()) +} diff --git a/crates/tinysecurity-policy/src/lib.rs b/crates/tinysecurity-policy/src/lib.rs index 248cf50f..ed3ed8d3 100644 --- a/crates/tinysecurity-policy/src/lib.rs +++ b/crates/tinysecurity-policy/src/lib.rs @@ -9,4 +9,4 @@ pub use engine::Policy; mod path_scope; pub use path_scope::{PathScope, protected_path, resolve_ancestor}; mod path_registry; -pub use path_registry::PathPolicyRegistry; +pub use path_registry::{PathPolicyRegistry, PreparedPathPolicy}; diff --git a/crates/tinysecurity-policy/src/path_registry.rs b/crates/tinysecurity-policy/src/path_registry.rs index 33eaf9ef..d468812d 100644 --- a/crates/tinysecurity-policy/src/path_registry.rs +++ b/crates/tinysecurity-policy/src/path_registry.rs @@ -3,32 +3,19 @@ use crate::PathScope; use std::sync::Arc; const HEX: &[u8; 16] = b"0123456789abcdef"; use tinysecurity_bus::{Error, PathPolicy, PathPolicyId, Result}; -/// Registry of immutable host-authorized scopes, preserved across SDK reinit. +/// A policy whose roots and reservation exceptions have passed admission checks. /// -/// This in-process library does not authenticate its Rust caller or confer OS -/// access. The host must keep registration and scope references behind its own -/// authenticated dispatch and private module bus. -#[derive(Debug, Default)] -pub struct PathPolicyRegistry { - scopes: Vec<(PathPolicyId, Arc)>, -} -impl PathPolicyRegistry { - /// Register or retrieve an identical immutable policy. +/// Preparing a policy may inspect the filesystem. Do this on a blocking worker, +/// before acquiring a shared registry lock. The contents cannot be constructed +/// or modified by callers; runtime checks still validate mutable symlinks. +#[derive(Debug)] +pub struct PreparedPathPolicy(PathPolicy); +impl PreparedPathPolicy { + /// Validate configuration without accessing the registry. /// /// # Errors - /// Rejects malformed roots, reservation exceptions that cannot resolve to - /// strict descendants, a full registry (1024 distinct scopes), entropy - /// failure, or repeated identifier collisions. - pub fn register(&mut self, policy: PathPolicy) -> Result { - self.register_with_entropy(policy, |bytes| { - getrandom::fill(bytes).map_err(|_| Error::Unavailable) - }) - } - fn register_with_entropy( - &mut self, - policy: PathPolicy, - mut entropy: impl FnMut(&mut [u8]) -> Result<()>, - ) -> Result { + /// Rejects malformed roots or exceptions that do not resolve to strict descendants. + pub fn new(policy: PathPolicy) -> Result { if !policy.workspace_dir.is_absolute() || !policy.action_dir.is_absolute() || policy @@ -55,6 +42,52 @@ impl PathPolicyRegistry { { return Err(Error::InvalidArguments); } + Ok(Self(policy)) + } +} +/// Registry of immutable host-authorized scopes, preserved across SDK reinit. +/// +/// This in-process library does not authenticate its Rust caller or confer OS +/// access. The host must keep registration and scope references behind its own +/// authenticated dispatch and private module bus. +#[derive(Debug, Default)] +pub struct PathPolicyRegistry { + scopes: Vec<(PathPolicyId, Arc)>, +} +impl PathPolicyRegistry { + /// Register or retrieve an identical immutable policy. + /// + /// # Errors + /// Rejects malformed roots, reservation exceptions that cannot resolve to + /// strict descendants, a full registry (1024 distinct scopes), entropy + /// failure, or repeated identifier collisions. + pub fn register(&mut self, policy: PathPolicy) -> Result { + self.register_with_entropy(policy, |bytes| { + getrandom::fill(bytes).map_err(|_| Error::Unavailable) + }) + } + /// Insert a previously validated policy without filesystem I/O. + /// + /// # Errors + /// Rejects a full registry, entropy failure, or repeated identifier collisions. + pub fn register_prepared(&mut self, policy: PreparedPathPolicy) -> Result { + self.insert_with_entropy(policy.0, |bytes| { + getrandom::fill(bytes).map_err(|_| Error::Unavailable) + }) + } + fn register_with_entropy( + &mut self, + policy: PathPolicy, + entropy: impl FnMut(&mut [u8]) -> Result<()>, + ) -> Result { + let policy = PreparedPathPolicy::new(policy)?.0; + self.insert_with_entropy(policy, entropy) + } + fn insert_with_entropy( + &mut self, + policy: PathPolicy, + mut entropy: impl FnMut(&mut [u8]) -> Result<()>, + ) -> Result { if let Some((id, _)) = self .scopes .iter() diff --git a/crates/tinysecurity-policy/src/path_scope.rs b/crates/tinysecurity-policy/src/path_scope.rs index a0deae01..e20b16f0 100644 --- a/crates/tinysecurity-policy/src/path_scope.rs +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -374,10 +374,10 @@ impl PathScope { return Err(Rejection::Protected); } if !self.string_allowed(input) { - return Err(if resolve_ancestor(&self.absolute(input)).is_err() { - Rejection::Resolution - } else { - Rejection::PolicyDenied + return Err(match resolve_ancestor(&self.absolute(input)) { + Err(_) => Rejection::Resolution, + Ok(resolved) if protected_path(&resolved) => Rejection::Protected, + Ok(_) => Rejection::PolicyDenied, }); } let absolute = self.absolute(input); diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index 0d51fe9b..36e69def 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -324,3 +324,53 @@ fn tilde_directory_reads_do_not_authorize_filename_less_parent_writes() )); Ok(()) } + +#[cfg(unix)] +#[test] +fn aliases_to_protected_roots_keep_the_protected_denial_category() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink("/etc", root.path().join("alias"))?; + let scope = scope(root.path())?; + for input in ["alias", "alias/passwd"] { + for write in [false, true] { + assert_eq!( + scope.validate(input, write), + PathValidationResult::Denied { + category: Rejection::Protected + } + ); + } + } + Ok(()) +} + +#[test] +fn workspace_inside_broad_forbidden_root_keeps_access_but_nested_forbidden_root_denies() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(workspace.join("private"))?; + std::fs::write(workspace.join("file"), "fixture")?; + std::fs::write(workspace.join("private/file"), "fixture")?; + let mut policy = scope(&workspace)?.policy().clone(); + policy.enabled = true; + policy.workspace_only = true; + policy.forbidden_paths = vec![root.path().to_string_lossy().into_owned(), "private".into()]; + 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 { .. } + )); + assert_eq!( + scope.validate("private/file", write), + PathValidationResult::Denied { + category: Rejection::PolicyDenied + } + ); + } + Ok(()) +} diff --git a/docs/specs/immutable-path-scopes.md b/docs/specs/immutable-path-scopes.md index 49826ee2..639ec9c4 100644 --- a/docs/specs/immutable-path-scopes.md +++ b/docs/specs/immutable-path-scopes.md @@ -15,7 +15,9 @@ process's home environment or imports a host crate. The registry assigns 256-bit OS-entropy references and deduplicates equal configurations and accepts at most 1024 distinct scopes per module process. Registration never replaces an existing scope. A full -registry fails closed; SDK reinitialization preserves registered scopes. A new +registry fails closed. Filesystem admission runs on a blocking worker before +acquiring the shared registry lock, so a stalled mount cannot block other scopes +or the module runtime. SDK reinitialization preserves registered scopes. A new module process requires fresh registration. Unknown identities fail as invalid arguments. Entropy failure or repeated identifier collisions also fail closed. References prevent insertion-index enumeration as defense in depth; they are not @@ -50,7 +52,9 @@ even with policy disabled. System paths include every Windows drive and native extended aliases, administrative UNC shares and GLOBALROOT device namespaces. Unlike the conservative bootstrap Check path contract, portable 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. Trusted grants override configurable forbidden roots only for operations their +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 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 From 551886140e2722c80ccdada599665f0b0dac5911 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:33:38 +0300 Subject: [PATCH 14/15] test(policy): use canonical roots for workspace precedence fixture Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope_contract_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index 36e69def..57298b62 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -349,14 +349,15 @@ fn aliases_to_protected_roots_keep_the_protected_denial_category() fn workspace_inside_broad_forbidden_root_keeps_access_but_nested_forbidden_root_denies() -> Result<(), Box> { let root = tempfile::tempdir()?; - let workspace = root.path().join("workspace"); + let canonical_root = root.path().canonicalize()?; + let workspace = canonical_root.join("workspace"); std::fs::create_dir_all(workspace.join("private"))?; std::fs::write(workspace.join("file"), "fixture")?; std::fs::write(workspace.join("private/file"), "fixture")?; let mut policy = scope(&workspace)?.policy().clone(); policy.enabled = true; policy.workspace_only = true; - policy.forbidden_paths = vec![root.path().to_string_lossy().into_owned(), "private".into()]; + policy.forbidden_paths = vec![canonical_root.to_string_lossy().into_owned(), "private".into()]; let mut registry = crate::PathPolicyRegistry::default(); let id = registry.register(policy)?; let scope = registry.get(&id)?; From b35eacad4c7ef07783a2e42ae8ed6291f3e85f44 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 10 Oct 2026 21:34:01 +0300 Subject: [PATCH 15/15] style(policy): format canonical workspace fixture Co-authored-by: Medulla --- crates/tinysecurity-policy/src/path_scope_contract_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs index 57298b62..f98e4518 100644 --- a/crates/tinysecurity-policy/src/path_scope_contract_tests.rs +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -357,7 +357,10 @@ fn workspace_inside_broad_forbidden_root_keeps_access_but_nested_forbidden_root_ let mut policy = scope(&workspace)?.policy().clone(); policy.enabled = true; policy.workspace_only = true; - policy.forbidden_paths = vec![canonical_root.to_string_lossy().into_owned(), "private".into()]; + policy.forbidden_paths = vec![ + canonical_root.to_string_lossy().into_owned(), + "private".into(), + ]; let mut registry = crate::PathPolicyRegistry::default(); let id = registry.register(policy)?; let scope = registry.get(&id)?;