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/Cargo.lock b/Cargo.lock index 3351d3e1..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" @@ -550,6 +568,7 @@ name = "tinysecurity-module" version = "0.2.1" dependencies = [ "serde_json", + "tempfile", "tinybus", "tinybus-module", "tinysecurity", @@ -561,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" @@ -665,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" @@ -712,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" @@ -815,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/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..ff469952 --- /dev/null +++ b/crates/tinysecurity-bus/src/path_policy.rs @@ -0,0 +1,141 @@ +//! 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 explicit child exceptions subject to other policy checks. +#[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. 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. +#[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 the requested operation; validation may inspect filesystem metadata. + 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..c03d405b --- /dev/null +++ b/crates/tinysecurity-bus/tests/path_wire.rs @@ -0,0 +1,152 @@ +//! 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(()) +} + +#[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(()) +} 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..6265a712 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,7 +89,9 @@ async fn main() -> std::result::Result<(), Box> { .await .is_err() ); + let (path_scope, fixture) = register_native_scope(&proxy).await?; verify_reconfiguration(&host, &proxy, &info.name).await?; + verify_native_scope(&proxy, path_scope, fixture.path()).await?; let evaluate = EvaluateRequest { caller, call: ToolCall { @@ -197,3 +199,94 @@ async fn verify_reconfiguration( assert_eq!(after.generation, before.generation + 1); Ok(()) } + +async fn register_native_scope( + proxy: &tinybus::Proxy, +) -> 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, + 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![], + }; + let id = proxy + .call(names::methods::REGISTER_PATH_POLICY, (policy,)) + .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: "file".into(), + }; + let read: PathValidationResult = proxy + .call(names::methods::VALIDATE_PATH, (request,)) + .await?; + 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: "prospective/path/file".into(), + },), + ) + .await?; + 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: native_protected_path(), + kind: PathCheckKind::Protected, + },), + ) + .await?; + assert!(protected); + println!("native immutable path scope survives accepted SDK reinitialization"); + Ok(()) +} + +fn native_protected_path() -> PathBuf { + #[cfg(windows)] + { + std::env::var_os("SystemRoot") + .map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from) + .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 4a47103c..45ba0f75 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1,19 +1,66 @@ //! 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)) + } + + 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() @@ -23,21 +70,53 @@ 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)) - }; - std::future::ready(result).await + 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))? + } + // 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, + ) -> BusResult { + self.register_path_with(policy, tinysecurity::PreparedPathPolicy::new) + .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 +155,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 +172,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..fe9333e2 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] @@ -7,6 +8,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: if cfg!(windows) { + PathBuf::from(std::env::var_os("SystemRoot").ok_or("SystemRoot absent")?) + .join("System32") + } else { + PathBuf::from("/etc/passwd") + }, + 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(()) +} + +#[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/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/lib.rs b/crates/tinysecurity-policy/src/lib.rs index 3b7b7cb8..ed3ed8d3 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, PreparedPathPolicy}; 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..d468812d --- /dev/null +++ b/crates/tinysecurity-policy/src/path_registry.rs @@ -0,0 +1,149 @@ +//! 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}; +/// A policy whose roots and reservation exceptions have passed admission checks. +/// +/// 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 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 + .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() + || crate::path_scope::reservation_exception_root(&root.path, path) + .is_none() + }) + }) + || 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); + } + 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() + .find(|(_, scope)| scope.policy() == &policy) + { + return Ok(id.clone()); + } + if self.scopes.len() >= 1024 { + return Err(Error::Unavailable); + } + 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::registered(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> { + self.scopes + .iter() + .find(|(registered, _)| registered == id) + .map(|(_, scope)| Arc::clone(scope)) + .ok_or(Error::InvalidArguments) + } + /// Retrieve only a previously registered immutable scope. + /// + /// # Errors + /// Rejects unknown identifiers; identifiers do not confer caller ownership. + pub fn get(&self, id: &PathPolicyId) -> Result<&PathScope> { + self.scopes + .iter() + .find(|(registered, _)| registered == id) + .map(|(_, scope)| scope.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..bb6ba353 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_registry_tests.rs @@ -0,0 +1,273 @@ +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(()) +} +/// 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(()) +} + +#[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 new file mode 100644 index 00000000..e20b16f0 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_scope.rs @@ -0,0 +1,426 @@ +//! 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 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 + .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")); + } + } + } + } +} +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()) +} +fn within(path: &Path, root: &Path) -> bool { + 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. +#[cfg(any(windows, target_os = "macos", test))] +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 { + // 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)] + { + 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()) + } + #[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)) +} +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 +/// use tinysecurity_policy::{PathScope, PathPolicy}; +/// fn bypass_registration(policy: PathPolicy) -> PathScope { PathScope(policy) } +/// ``` +#[derive(Clone, Debug)] +pub struct PathScope(PathPolicy); +impl std::ops::Deref for PathScope { + type Target = PathPolicy; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +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 { + let resolved = canonical_or_original(path); + self.reserved_paths.iter().any(|reservation| { + 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| { + 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) { + return false; + } + resolved + .components() + .nth(root.components().count()) + .is_some_and(|component| { + let name = component.as_os_str().to_string_lossy(); + #[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()) + }) + }) + } + /// Whether an explicitly trusted root grants this operation. + #[must_use] + pub fn trusted(&self, path: &Path, write: bool) -> bool { + 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 { + 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_denied(path, root))) + { + return false; + } + within(path, &self.workspace_dir) || self.trusted_canonical(path, write) + } + 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() && deny_starts_with(&workspace, &forbidden) { + return false; + } + let forbidden = if forbidden.is_absolute() { + forbidden + } else { + workspace.join(forbidden) + }; + within_denied(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 { + if input.is_empty() { + return false; + } + let expanded = expand(self, input); + if ambiguous_native(&expanded) || protected_path(&expanded) { + return false; + } + if !expanded.is_absolute() + && expanded + .components() + .filter(|part| !matches!(part, std::path::Component::CurDir)) + .count() + == 1 + && self.reserved_names.iter().any(|name| { + expanded + .file_name() + .is_some_and(|file| deny_component_eq(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).is_ok_and(|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; + } + 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 + } + /// 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 input.is_empty() + || ambiguous_native(&expand(self, input)) + || (write + && Path::new(input.strip_prefix("~/").unwrap_or(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(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); + 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 metadata_present(absolute.symlink_metadata().map(|_| ()))? { + 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, write) { + 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; + +#[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..f98e4518 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_scope_contract_tests.rs @@ -0,0 +1,380 @@ +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"))); +} + +#[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 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(()) +} + +#[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(()) +} + +#[test] +fn prospective_targets_require_not_found_rather_than_uninspectable_metadata() { + use std::io::{Error, ErrorKind}; + assert_eq!(metadata_present(Ok(())), Ok(true)); + assert_eq!( + metadata_present(Err(Error::from(ErrorKind::NotFound))), + Ok(false) + ); + for error in [ + ErrorKind::PermissionDenied, + ErrorKind::Other, + ErrorKind::InvalidInput, + ] { + assert_eq!( + metadata_present(Err(Error::from(error))), + Err(Rejection::Resolution) + ); + } +} + +#[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(()) +} + +#[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 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![ + 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)?; + 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/crates/tinysecurity-policy/src/path_scope_tests.rs b/crates/tinysecurity-policy/src/path_scope_tests.rs new file mode 100644 index 00000000..fca7f1b3 --- /dev/null +++ b/crates/tinysecurity-policy/src/path_scope_tests.rs @@ -0,0 +1,521 @@ +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) -> tinysecurity_bus::Result { + let mut registry = crate::PathPolicyRegistry::default(); + let id = registry.register(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()], + })?; + Ok(registry.get(&id)?.clone()) +} +#[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)); + #[cfg(windows)] + let protected = + PathBuf::from(std::env::var_os("SystemRoot").ok_or("SystemRoot absent")?).join("System32"); + #[cfg(not(windows))] + let protected = PathBuf::from("/etc/passwd"); + assert!(scope.check_path(&protected, PathCheckKind::Protected)); + scope.0.enabled = false; + let outside = tempfile::tempdir()?; + assert!(scope.resolved_allowed(&outside.path().join("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}" + ); + } +} +#[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}" + ); + } +} +/// 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 new file mode 100644 index 00000000..639ec9c4 --- /dev/null +++ b/docs/specs/immutable-path-scopes.md @@ -0,0 +1,118 @@ +# 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 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. 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 +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. + +`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. 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 +workspace root. Reserved single-component filenames retain the host's lexical +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 +only children so an artifact container remains listable. Read-only subtrees +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 +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 +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. + +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. + +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.