From d7926c8bf3c318f75acf0785756faafc4ea707e7 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Sat, 19 Sep 2026 16:28:15 -0700 Subject: [PATCH 1/4] fix(network): normalize Windows policy binary paths Match Windows executable identities using a stable case-insensitive, separator-normalized representation across policy data, L4 input, and L7 relay evaluation. Preserve exact matching on other platforms and keep the original path for hashing and filesystem access. NVBug 6782969 Signed-off-by: Prekshi Vyas --- .../openshell-supervisor-network/src/opa.rs | 146 +++++++++++++++++- .../src/proxy/relay.rs | 4 +- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 31fc7f99ee..61a2ddd6c6 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -448,6 +448,7 @@ impl OpaEngine { let mut data: serde_json::Value = serde_json::from_str(&data_json_str) .map_err(|e| miette::miette!("internal: failed to parse proto JSON: {e}"))?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Validate BEFORE expanding presets @@ -1141,7 +1142,7 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { let ancestor_strs: Vec = input .ancestors .iter() - .map(|p| p.to_string_lossy().into_owned()) + .map(|p| network_binary_match_path(p)) .collect(); let cmdline_strs: Vec = input .cmdline_paths @@ -1150,7 +1151,7 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { .collect(); serde_json::json!({ "exec": { - "path": input.binary_path.to_string_lossy(), + "path": network_binary_match_path(&input.binary_path), "ancestors": ancestor_strs, "cmdline_paths": cmdline_strs, }, @@ -1161,6 +1162,29 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { }) } +/// Return the stable representation used only for network-policy path matching. +/// +/// Windows paths are case-insensitive by default and accept either path separator. +/// Normalizing both policy data and runtime input prevents equivalent spellings from +/// being denied while leaving the original path intact for filesystem access and +/// executable hashing. Other platforms retain exact path matching. +pub(crate) fn network_binary_match_path(path: &Path) -> String { + let path = path.to_string_lossy(); + #[cfg(target_os = "windows")] + { + windows_network_binary_match_path(&path) + } + #[cfg(not(target_os = "windows"))] + { + path.into_owned() + } +} + +#[cfg(any(target_os = "windows", test))] +fn windows_network_binary_match_path(path: &str) -> String { + path.replace('\\', "/").to_ascii_lowercase() +} + /// Sets an already-built JSON value as Regorus input without encoding and reparsing JSON text. /// /// The explicit fallible conversion preserves evaluator errors because Regorus's infallible @@ -1463,6 +1487,7 @@ fn preprocess_yaml_data( .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; validate_opa_data_structure(&data)?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Normalize port → ports for all endpoints so Rego always sees "ports" array. @@ -1564,6 +1589,35 @@ fn normalize_endpoint_protocols(data: &mut serde_json::Value) { } } +/// Normalize configured binary paths to the same platform-specific representation +/// used for runtime process identity before any Rego evaluation. +fn normalize_network_binary_paths(data: &mut serde_json::Value) { + let Some(policies) = data + .get_mut("network_policies") + .and_then(serde_json::Value::as_object_mut) + else { + return; + }; + + for policy in policies.values_mut() { + let Some(binaries) = policy + .get_mut("binaries") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + for binary in binaries { + let Some(path) = binary.get_mut("path") else { + continue; + }; + let Some(value) = path.as_str() else { + continue; + }; + *path = network_binary_match_path(Path::new(value)).into(); + } + } +} + /// Normalize endpoint port/ports in JSON data. /// /// YAML policies may use `port: N` (single) or `ports: [N, M]` (multi). @@ -2397,6 +2451,16 @@ mod tests { OpaEngine::from_strings(TEST_POLICY, TEST_DATA_YAML).expect("Failed to load test policy") } + #[test] + fn windows_binary_match_path_normalizes_case_and_separators() { + let normalized = windows_network_binary_match_path(r"C:\WINDOWS\SYSTEM32\CURL.EXE"); + assert_eq!(normalized, "c:/windows/system32/curl.exe"); + assert_ne!( + normalized, + windows_network_binary_match_path(r"C:\Windows\System32\powershell.exe") + ); + } + fn opa_container_policy() -> serde_json::Value { serde_json::json!({ "filesystem_policy": {}, @@ -3420,6 +3484,84 @@ network_policies: assert_eq!(decision.matched_policy.as_deref(), Some("claude_code")); } + #[cfg(target_os = "windows")] + #[test] + fn from_proto_matches_windows_equivalent_binary_path() { + let mut proto = openshell_policy::restrictive_default_policy(); + proto.network_policies.insert( + "windows_binary".to_string(), + NetworkPolicyRule { + name: "windows_binary".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: r"C:\WINDOWS\SYSTEM32\CURL.EXE".to_string(), + }], + }, + ); + let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); + + let equivalent = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let decision = engine.evaluate_network(&equivalent).unwrap(); + assert!( + decision.allowed, + "Windows-equivalent binary path should be allowed: {}", + decision.reason + ); + + let different_binary = NetworkInput { + binary_path: PathBuf::from("c:/windows/system32/powershell.exe"), + ..equivalent + }; + let decision = engine.evaluate_network(&different_binary).unwrap(); + assert!( + !decision.allowed, + "normalization must not allow a different binary" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn from_strings_matches_windows_equivalent_binary_path() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + r#" +network_policies: + windows_binary: + endpoints: + - { host: example.com, port: 443 } + binaries: + - { path: 'C:\WINDOWS\SYSTEM32\CURL.EXE' } +"#, + ) + .expect("Failed to create engine from YAML"); + let input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + + let decision = engine.evaluate_network(&input).unwrap(); + assert!( + decision.allowed, + "Windows-equivalent YAML binary path should be allowed: {}", + decision.reason + ); + } + #[test] fn from_proto_denies_unmatched_request() { let proto = test_proto(); diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 4c89e5e382..fdd7031a53 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -76,12 +76,12 @@ pub(super) fn http_context( binary_path: decision .binary .as_ref() - .map(|path| path.to_string_lossy().into_owned()) + .map(|path| crate::opa::network_binary_match_path(path)) .unwrap_or_default(), ancestors: decision .ancestors .iter() - .map(|path| path.to_string_lossy().into_owned()) + .map(|path| crate::opa::network_binary_match_path(path)) .collect(), cmdline_paths: decision .cmdline_paths From 18174bfd5bfad4c75e92d311edbf432933de49a8 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 22 Sep 2026 00:15:38 -0700 Subject: [PATCH 2/4] fix(network): harden Windows binary matching Signed-off-by: Prekshi Vyas --- Cargo.lock | 1 + .../openshell-supervisor-network/Cargo.toml | 3 + .../openshell-supervisor-network/src/opa.rs | 109 +++++++++++++++++- .../src/proxy/relay.rs | 109 +++++++++++++++++- docs/reference/policy-schema.mdx | 8 ++ docs/security/best-practices.mdx | 8 ++ 6 files changed, 232 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 431b79335a..9408792da7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4684,6 +4684,7 @@ dependencies = [ "tracing-subscriber", "uuid", "webpki-roots", + "windows", ] [[package]] diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index e4a2f12917..23db6efcfe 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -69,6 +69,9 @@ tokio-stream = { workspace = true, features = ["net"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } + [target.'cfg(unix)'.dev-dependencies] [lints] diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 61a2ddd6c6..26da10f6c2 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1164,10 +1164,10 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { /// Return the stable representation used only for network-policy path matching. /// -/// Windows paths are case-insensitive by default and accept either path separator. -/// Normalizing both policy data and runtime input prevents equivalent spellings from -/// being denied while leaving the original path intact for filesystem access and -/// executable hashing. Other platforms retain exact path matching. +/// Windows paths accept either path separator and are usually case-insensitive. +/// Case folding is applied only when Windows confirms that the containing directory +/// is case-insensitive. Case-sensitive, indeterminate, and verbatim paths retain +/// their spelling so matching fails closed. Other platforms retain exact matching. pub(crate) fn network_binary_match_path(path: &Path) -> String { let path = path.to_string_lossy(); #[cfg(target_os = "windows")] @@ -1182,7 +1182,85 @@ pub(crate) fn network_binary_match_path(path: &Path) -> String { #[cfg(any(target_os = "windows", test))] fn windows_network_binary_match_path(path: &str) -> String { - path.replace('\\', "/").to_ascii_lowercase() + #[cfg(target_os = "windows")] + let case_sensitive = windows_path_case_sensitive(Path::new(path)); + #[cfg(not(target_os = "windows"))] + let case_sensitive = Some(false); + + windows_network_binary_match_path_with_case_sensitivity(path, case_sensitive) +} + +#[cfg(any(target_os = "windows", test))] +fn windows_network_binary_match_path_with_case_sensitivity( + path: &str, + case_sensitive: Option, +) -> String { + if is_windows_verbatim_path(path) { + return path.to_owned(); + } + + let normalized = path.replace('\\', "/"); + if case_sensitive.unwrap_or(true) { + return normalized; + } + normalized.to_ascii_lowercase() +} + +#[cfg(any(target_os = "windows", test))] +fn is_windows_verbatim_path(path: &str) -> bool { + path.starts_with(r"\\?\") || path.starts_with("//?/") +} + +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn windows_path_case_sensitive(path: &Path) -> Option { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_CASE_SENSITIVE_INFO, FileCaseSensitiveInfo, GetFileInformationByHandleEx, + }; + + if path.to_string_lossy().contains(['*', '?']) { + return None; + } + let directory = path.parent()?; + let directory = if directory.as_os_str().is_empty() { + Path::new(".") + } else { + directory + }; + let handle = open_windows_directory_for_attributes(directory)?; + let mut info = FILE_CASE_SENSITIVE_INFO::default(); + let info_size = u32::try_from(size_of::()).ok()?; + // SAFETY: `handle` stays alive for the call and `info` is the exact buffer + // type and size required by `FileCaseSensitiveInfo`. + unsafe { + GetFileInformationByHandleEx( + HANDLE(handle.as_raw_handle()), + FileCaseSensitiveInfo, + (&raw mut info).cast(), + info_size, + ) + } + .ok()?; + Some(info.Flags & 1 != 0) +} + +#[cfg(target_os = "windows")] +fn open_windows_directory_for_attributes(path: &Path) -> Option { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .ok() } /// Sets an already-built JSON value as Regorus input without encoding and reparsing JSON text. @@ -2461,6 +2539,27 @@ mod tests { ); } + #[test] + fn windows_binary_match_path_preserves_verbatim_paths() { + let verbatim = r"\\?\C:\Windows\System32\CURL.EXE"; + assert_eq!(windows_network_binary_match_path(verbatim), verbatim); + } + + #[test] + fn windows_binary_match_path_distinguishes_case_sensitive_directory_entries() { + assert_ne!( + windows_network_binary_match_path_with_case_sensitivity( + r"C:\case-sensitive\Trusted.exe", + Some(true), + ), + windows_network_binary_match_path_with_case_sensitivity( + r"C:\case-sensitive\trusted.exe", + Some(true), + ), + "case-distinct files must not share network permissions" + ); + } + fn opa_container_policy() -> serde_json::Value { serde_json::json!({ "filesystem_policy": {}, diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index fdd7031a53..ca9559cd6d 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -330,8 +330,13 @@ fn emit_stale_relay_close(request: &L7EvalContext, guard: &PolicyGenerationGuard #[cfg(test)] mod tests { - use super::super::{EgressIntent, EndpointDecision, ProcessIdentityEvidence}; + use super::super::{ + EgressIntent, EndpointDecision, ProcessIdentityEvidence, query_l7_route_snapshot, + }; use super::*; + use crate::opa::NetworkInput; + use std::path::PathBuf; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; @@ -374,6 +379,108 @@ mod tests { } } + #[cfg(target_os = "windows")] + #[tokio::test] + async fn inspected_http_relay_matches_mixed_case_windows_binary_path() { + let (mut caller, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut server) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + let engine = OpaEngine::from_strings( + POLICY_REGO, + r" +network_policies: + windows_binary: + endpoints: + - host: example.com + port: 80 + protocol: rest + access: full + binaries: + - path: 'C:\WINDOWS\SYSTEM32\CURL.EXE' +", + ) + .expect("load Windows L7 policy"); + let input = NetworkInput { + host: "example.com".to_string(), + port: 80, + binary_path: PathBuf::from("c:/windows/system32/curl.exe"), + binary_sha256: "unused".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let authorization = engine.authorize_egress(&input).expect("authorize egress"); + assert!(matches!(authorization.action, NetworkAction::Allow { .. })); + let decision = EgressDecision { + intent: EgressIntent::connect("example.com".to_string(), 80), + action: authorization.action.clone(), + policy_generation: authorization.generation, + identity: ProcessIdentityEvidence::Available, + endpoint: EndpointDecision::from_authorization(&authorization), + binary: Some(input.binary_path), + binary_pid: None, + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }; + let route = query_l7_route_snapshot(&decision, "example.com", 80) + .expect("REST endpoint should produce an inspected route"); + let mut request = http_context( + &decision, + None, + None, + None, + openshell_core::proposals::AgentProposals::default(), + String::new(), + RelaySignals { + activity: None, + endpoint_observation: None, + }, + ); + request.request_default_port = Some(80); + let context = prepare_http_relay(Some(&route), &engine, &decision, &request) + .expect("current policy generation should prepare the relay"); + relay_http_stream(&mut relay_client, &mut relay_upstream, context).await + }); + + caller + .write_all(b"GET /v1 HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n") + .await + .expect("write client request"); + let mut forwarded = [0_u8; 512]; + let forwarded_len = tokio::time::timeout( + std::time::Duration::from_secs(2), + server.read(&mut forwarded), + ) + .await + .expect("request should reach upstream") + .expect("read relayed request"); + assert!( + forwarded[..forwarded_len].starts_with(b"GET /v1 HTTP/1.1\r\n"), + "unexpected upstream request: {:?}", + String::from_utf8_lossy(&forwarded[..forwarded_len]) + ); + server + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .expect("write upstream response"); + + let mut response = [0_u8; 512]; + let response_len = tokio::time::timeout( + std::time::Duration::from_secs(2), + caller.read(&mut response), + ) + .await + .expect("response should reach client") + .expect("read relay response"); + assert!(response[..response_len].starts_with(b"HTTP/1.1 204 No Content")); + drop(caller); + drop(server); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("HTTP relay should complete") + .expect("relay task should not panic") + .expect("HTTP relay should allow the normalized binary path"); + } + #[test] fn relay_without_route_pins_l4_decision_generation() { let engine = OpaEngine::from_strings(POLICY_REGO, EMPTY_POLICY_DATA).unwrap(); diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 36bfa0778a..64df8b0af9 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -584,6 +584,14 @@ Identifies an executable that is permitted to use the associated endpoints. |---|---|---|---| | `path` | string | Yes | Filesystem path to the executable. Supports glob patterns with `*` and `**`. For example, `/sandbox/.vscode-server/**` matches any executable under that directory tree. | +On Windows, ordinary binary paths treat `/` and `\` as equivalent. ASCII case is +folded only when Windows confirms that the containing directory is +case-insensitive. Paths in case-sensitive directories, paths whose directory +semantics cannot be determined, and paths containing glob metacharacters retain +their case and therefore fail closed on case differences. Verbatim `\\?\` paths +retain their exact spelling and separators; policy authors should use the same +verbatim form reported by the runtime. + ## Network Middleware **Category:** Dynamic diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index bd7cd5ff61..60009e25ef 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -90,6 +90,14 @@ The proxy SHA256-hashes each binary on first use (trust-on-first-use). If someon | Risk if relaxed | Broad glob patterns (like `/**`) allow any binary to reach the endpoint, defeating the purpose of binary-scoped enforcement. | | Recommendation | Scope binaries to the specific executables that need each endpoint. Use narrow globs when the exact path varies (for example, across Python virtual environments). | +On Windows, OpenShell normalizes `/` and `\` for ordinary binary paths and folds +ASCII case only when the containing directory is confirmed case-insensitive. +Case-sensitive, indeterminate, and globbed paths retain case so a case-distinct +executable cannot inherit another executable's grant. Verbatim `\\?\` paths are +matched exactly without separator or case rewriting. Prefer ordinary absolute +paths; when a verbatim path is necessary, copy the exact runtime-reported form +into the policy. + ### L4-Only vs L7 Inspection The `protocol` field on an endpoint controls whether the proxy inspects individual HTTP requests inside the tunnel. From 80fcc58d17cebfa8eefc294c3fa41dd3116dbf55 Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 22 Sep 2026 00:40:14 -0700 Subject: [PATCH 3/4] fix(ci): scope Windows relay test imports --- crates/openshell-supervisor-network/src/proxy/relay.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index ca9559cd6d..0d710ce9a9 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -330,12 +330,15 @@ fn emit_stale_relay_close(request: &L7EvalContext, guard: &PolicyGenerationGuard #[cfg(test)] mod tests { - use super::super::{ - EgressIntent, EndpointDecision, ProcessIdentityEvidence, query_l7_route_snapshot, - }; + #[cfg(target_os = "windows")] + use super::super::query_l7_route_snapshot; + use super::super::{EgressIntent, EndpointDecision, ProcessIdentityEvidence}; use super::*; + #[cfg(target_os = "windows")] use crate::opa::NetworkInput; + #[cfg(target_os = "windows")] use std::path::PathBuf; + #[cfg(target_os = "windows")] use tokio::io::{AsyncReadExt, AsyncWriteExt}; const POLICY_REGO: &str = include_str!("../../data/sandbox-policy.rego"); From a882d2b469ff19dad7c36021df60b410cc9e3dec Mon Sep 17 00:00:00 2001 From: Prekshi Vyas Date: Tue, 22 Sep 2026 13:05:52 -0700 Subject: [PATCH 4/4] fix(network): harden Windows binary path matching --- .../data/sandbox-policy.rego | 50 +++- .../src/l7/relay.rs | 1 + .../openshell-supervisor-network/src/opa.rs | 279 ++++++++++++++---- .../openshell-supervisor-network/src/proxy.rs | 10 +- .../src/proxy/egress.rs | 2 + .../src/proxy/relay.rs | 11 +- .../src/proxy/tests/compatibility.rs | 1 + docs/reference/policy-schema.mdx | 11 +- docs/security/best-practices.mdx | 12 +- 9 files changed, 293 insertions(+), 84 deletions(-) diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 278291a288..1c10eb0565 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -141,25 +141,54 @@ binary_allowed(_, _) if { not binary_identity_required } +# Binary path matching uses exact spelling by default. On Windows, Rust adds an +# ASCII-folded candidate only after querying every parent directory of the +# runtime-observed executable and confirming case-insensitive lookup throughout. +binary_path_matches(binary, candidate) if { + binary.path == candidate.path +} + +binary_path_matches(binary, candidate) if { + candidate.ascii_case_folded != "" + binary.ascii_case_folded_path == candidate.ascii_case_folded +} + +binary_glob_matches(binary, candidate) if { + glob.match(binary.path, ["/"], candidate.path) +} + +binary_glob_matches(binary, candidate) if { + candidate.ascii_case_folded != "" + glob.match(binary.ascii_case_folded_path, ["/"], candidate.ascii_case_folded) +} + # Binary matching: exact path. # SHA256 integrity is enforced in Rust via trust-on-first-use (TOFU) cache, # not in Rego. The proxy computes and caches binary hashes at runtime. binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries not contains(b.path, "*") b.path == exec.path } # Binary matching: ancestor exact path (e.g., claude spawns node). binary_allowed(policy, exec) if { - some b - b := policy.binaries[_] + some b in policy.binaries not contains(b.path, "*") - ancestor := exec.ancestors[_] + some ancestor in exec.ancestors b.path == ancestor } +# Trusted runtime evidence may additionally authorize an ASCII case-folded +# Windows path. Legacy and synthetic L7 inputs omit match_paths and continue to +# use only the exact rules above. +binary_allowed(policy, exec) if { + some b in policy.binaries + not contains(b.path, "*") + some candidate in exec.match_paths + binary_path_matches(b, candidate) +} + # Binary matching: glob pattern against exe path or any ancestor. # NOTE: cmdline_paths are intentionally excluded — argv[0] is trivially # spoofable via execve and must not be used as a grant-access signal. @@ -167,8 +196,15 @@ binary_allowed(policy, exec) if { some b in policy.binaries contains(b.path, "*") all_paths := array.concat([exec.path], exec.ancestors) - some p in all_paths - glob.match(b.path, ["/"], p) + some path in all_paths + glob.match(b.path, ["/"], path) +} + +binary_allowed(policy, exec) if { + some b in policy.binaries + contains(b.path, "*") + some candidate in exec.match_paths + binary_glob_matches(b, candidate) } # --- Network action (allow / deny) --- diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 31acd3a7fd..7db191e574 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -2935,6 +2935,7 @@ fn evaluate_l7_request_once( "path": ctx.binary_path, "ancestors": ctx.ancestors, "cmdline_paths": ctx.cmdline_paths, + "match_paths": engine.binary_match_paths(), }, "request": { "method": request.action, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 26da10f6c2..a4bf2c319b 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -78,6 +78,7 @@ pub struct EgressAuthorization { pub matched_endpoints: Vec, pub exact_declared_endpoint_host: bool, pub generation: u64, + pub(crate) binary_match_paths: Vec, } /// Input for a network access policy evaluation. @@ -230,6 +231,7 @@ pub struct TunnelPolicyEngine { generation_guard: PolicyGenerationGuard, middleware_runner: ChainRunner, websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, + binary_match_paths: Vec, } impl TunnelPolicyEngine { @@ -257,6 +259,10 @@ impl TunnelPolicyEngine { &self.middleware_runner } + pub(crate) fn binary_match_paths(&self) -> &[NetworkBinaryPathEvidence] { + &self.binary_match_paths + } + pub(crate) fn websocket_assembly_budget( &self, ) -> crate::l7::websocket::WebSocketAssemblyBudget { @@ -555,7 +561,8 @@ impl OpaEngine { #[cfg(test)] record_test_opa_query(); - let input_json = network_input_json(input); + let binary_match_paths = network_binary_path_evidence_for_input(input); + let input_json = network_input_json_with_match_paths(input, &binary_match_paths); let mut engine = self .engine @@ -575,6 +582,7 @@ impl OpaEngine { matched_endpoints: Vec::new(), exact_declared_endpoint_host: false, generation, + binary_match_paths, }); } @@ -614,6 +622,7 @@ impl OpaEngine { matched_endpoints, exact_declared_endpoint_host, generation, + binary_match_paths, }) } @@ -1061,6 +1070,14 @@ impl OpaEngine { /// and only duplicates interpreter state (~microseconds). The cloned /// engine can be used without Mutex contention. pub fn clone_engine_for_tunnel(&self, expected_generation: u64) -> Result { + self.clone_engine_for_tunnel_with_match_paths(expected_generation, Vec::new()) + } + + pub(crate) fn clone_engine_for_tunnel_with_match_paths( + &self, + expected_generation: u64, + binary_match_paths: Vec, + ) -> Result { let engine = self .engine .lock() @@ -1080,6 +1097,7 @@ impl OpaEngine { }, middleware_runner: self.middleware_runner()?, websocket_assembly_budget: self.websocket_assembly_budget(), + binary_match_paths, }) } } @@ -1139,10 +1157,31 @@ fn get_str_array(val: ®orus::Value, key: &str) -> Vec { } fn network_input_json(input: &NetworkInput) -> serde_json::Value { - let ancestor_strs: Vec = input - .ancestors + let match_paths = network_binary_path_evidence_for_input(input); + network_input_json_with_match_paths(input, &match_paths) +} + +fn network_binary_path_evidence_for_input(input: &NetworkInput) -> Vec { + let mut match_paths = Vec::with_capacity(input.ancestors.len() + 1); + match_paths.push(network_binary_path_evidence(&input.binary_path)); + match_paths.extend( + input + .ancestors + .iter() + .map(|path| network_binary_path_evidence(path)), + ); + match_paths +} + +fn network_input_json_with_match_paths( + input: &NetworkInput, + match_paths: &[NetworkBinaryPathEvidence], +) -> serde_json::Value { + let binary = &match_paths[0]; + let ancestor_strs: Vec<&str> = match_paths .iter() - .map(|p| network_binary_match_path(p)) + .skip(1) + .map(|path| path.path.as_str()) .collect(); let cmdline_strs: Vec = input .cmdline_paths @@ -1151,9 +1190,10 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { .collect(); serde_json::json!({ "exec": { - "path": network_binary_match_path(&input.binary_path), + "path": &binary.path, "ancestors": ancestor_strs, "cmdline_paths": cmdline_strs, + "match_paths": match_paths, }, "network": { "host": input.host, @@ -1164,10 +1204,9 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { /// Return the stable representation used only for network-policy path matching. /// -/// Windows paths accept either path separator and are usually case-insensitive. -/// Case folding is applied only when Windows confirms that the containing directory -/// is case-insensitive. Case-sensitive, indeterminate, and verbatim paths retain -/// their spelling so matching fails closed. Other platforms retain exact matching. +/// Windows paths accept either path separator. Namespace paths retain their exact +/// spelling, and other platforms retain exact matching. This lexical operation +/// never probes the filesystem, so it is also safe for policy-authored paths. pub(crate) fn network_binary_match_path(path: &Path) -> String { let path = path.to_string_lossy(); #[cfg(target_os = "windows")] @@ -1182,38 +1221,44 @@ pub(crate) fn network_binary_match_path(path: &Path) -> String { #[cfg(any(target_os = "windows", test))] fn windows_network_binary_match_path(path: &str) -> String { - #[cfg(target_os = "windows")] - let case_sensitive = windows_path_case_sensitive(Path::new(path)); - #[cfg(not(target_os = "windows"))] - let case_sensitive = Some(false); - - windows_network_binary_match_path_with_case_sensitivity(path, case_sensitive) + if is_windows_namespace_path(path) { + return path.to_owned(); + } + path.replace('\\', "/") } #[cfg(any(target_os = "windows", test))] -fn windows_network_binary_match_path_with_case_sensitivity( - path: &str, - case_sensitive: Option, -) -> String { - if is_windows_verbatim_path(path) { - return path.to_owned(); - } +fn is_windows_namespace_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 2 && matches!(bytes[0], b'/' | b'\\') && matches!(bytes[1], b'/' | b'\\') +} - let normalized = path.replace('\\', "/"); - if case_sensitive.unwrap_or(true) { - return normalized; - } - normalized.to_ascii_lowercase() +#[derive(Clone, Debug, serde::Serialize)] +pub(crate) struct NetworkBinaryPathEvidence { + path: String, + /// Present only when trusted runtime evidence confirms that every parent + /// directory uses case-insensitive lookup. + ascii_case_folded: String, } -#[cfg(any(target_os = "windows", test))] -fn is_windows_verbatim_path(path: &str) -> bool { - path.starts_with(r"\\?\") || path.starts_with("//?/") +fn network_binary_path_evidence(path: &Path) -> NetworkBinaryPathEvidence { + let normalized = network_binary_match_path(path); + #[cfg(target_os = "windows")] + let ascii_case_folded = windows_path_components_case_insensitive(path) + .filter(|case_insensitive| *case_insensitive) + .map_or_else(String::new, |_| normalized.to_ascii_lowercase()); + #[cfg(not(target_os = "windows"))] + let ascii_case_folded = String::new(); + + NetworkBinaryPathEvidence { + path: normalized, + ascii_case_folded, + } } #[cfg(target_os = "windows")] #[allow(unsafe_code)] -fn windows_path_case_sensitive(path: &Path) -> Option { +fn windows_directory_case_sensitive(path: &Path) -> Option { use std::mem::size_of; use std::os::windows::io::AsRawHandle; use windows::Win32::Foundation::HANDLE; @@ -1221,16 +1266,7 @@ fn windows_path_case_sensitive(path: &Path) -> Option { FILE_CASE_SENSITIVE_INFO, FileCaseSensitiveInfo, GetFileInformationByHandleEx, }; - if path.to_string_lossy().contains(['*', '?']) { - return None; - } - let directory = path.parent()?; - let directory = if directory.as_os_str().is_empty() { - Path::new(".") - } else { - directory - }; - let handle = open_windows_directory_for_attributes(directory)?; + let handle = open_windows_directory_for_attributes(path)?; let mut info = FILE_CASE_SENSITIVE_INFO::default(); let info_size = u32::try_from(size_of::()).ok()?; // SAFETY: `handle` stays alive for the call and `info` is the exact buffer @@ -1247,6 +1283,29 @@ fn windows_path_case_sensitive(path: &Path) -> Option { Some(info.Flags & 1 != 0) } +#[cfg(target_os = "windows")] +fn windows_path_components_case_insensitive(path: &Path) -> Option { + let text = path.to_string_lossy(); + let bytes = text.as_bytes(); + if text.contains(['*', '?']) + || is_windows_namespace_path(&text) + || bytes.len() < 3 + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + || !matches!(bytes[2], b'/' | b'\\') + { + return None; + } + + let parent = path.parent()?; + for directory in parent.ancestors().filter(|p| !p.as_os_str().is_empty()) { + if windows_directory_case_sensitive(directory)? { + return Some(false); + } + } + Some(true) +} + #[cfg(target_os = "windows")] fn open_windows_directory_for_attributes(path: &Path) -> Option { use std::os::windows::fs::OpenOptionsExt; @@ -1667,8 +1726,11 @@ fn normalize_endpoint_protocols(data: &mut serde_json::Value) { } } -/// Normalize configured binary paths to the same platform-specific representation -/// used for runtime process identity before any Rego evaluation. +/// Normalize configured binary paths without accessing the filesystem. +/// +/// Policy paths are untrusted input and may name remote shares or device +/// namespaces. Case-insensitive matching is therefore decided later using only +/// trusted runtime path evidence. fn normalize_network_binary_paths(data: &mut serde_json::Value) { let Some(policies) = data .get_mut("network_policies") @@ -1685,13 +1747,18 @@ fn normalize_network_binary_paths(data: &mut serde_json::Value) { continue; }; for binary in binaries { - let Some(path) = binary.get_mut("path") else { + let Some(binary) = binary.as_object_mut() else { continue; }; - let Some(value) = path.as_str() else { + let Some(value) = binary.get("path").and_then(serde_json::Value::as_str) else { continue; }; - *path = network_binary_match_path(Path::new(value)).into(); + let normalized = network_binary_match_path(Path::new(value)); + binary.insert( + "ascii_case_folded_path".to_string(), + normalized.to_ascii_lowercase().into(), + ); + binary.insert("path".to_string(), normalized.into()); } } } @@ -2532,7 +2599,7 @@ mod tests { #[test] fn windows_binary_match_path_normalizes_case_and_separators() { let normalized = windows_network_binary_match_path(r"C:\WINDOWS\SYSTEM32\CURL.EXE"); - assert_eq!(normalized, "c:/windows/system32/curl.exe"); + assert_eq!(normalized, "C:/WINDOWS/SYSTEM32/CURL.EXE"); assert_ne!( normalized, windows_network_binary_match_path(r"C:\Windows\System32\powershell.exe") @@ -2540,23 +2607,113 @@ mod tests { } #[test] - fn windows_binary_match_path_preserves_verbatim_paths() { - let verbatim = r"\\?\C:\Windows\System32\CURL.EXE"; - assert_eq!(windows_network_binary_match_path(verbatim), verbatim); + fn windows_binary_match_path_preserves_namespace_paths_without_probing() { + for namespace_path in [ + r"\\?\C:\Windows\System32\CURL.EXE", + r"\\.\PhysicalDrive0", + r"\\server\share\tool.exe", + ] { + assert_eq!( + windows_network_binary_match_path(namespace_path), + namespace_path + ); + } + } + + #[cfg(target_os = "windows")] + #[allow(unsafe_code)] + fn set_windows_directory_case_sensitive(path: &Path, enabled: bool) { + use std::mem::size_of; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + FILE_CASE_SENSITIVE_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, + FileCaseSensitiveInfo, SetFileInformationByHandle, + }; + + let handle = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES.0 | FILE_WRITE_ATTRIBUTES.0) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0) + .open(path) + .expect("open temporary directory for case-sensitivity update"); + let info = FILE_CASE_SENSITIVE_INFO { + Flags: u32::from(enabled), + }; + let info_size = u32::try_from(size_of::()) + .expect("case-sensitivity info size fits in u32"); + // SAFETY: `handle` and `info` remain alive for the call, and the buffer + // has the exact type and size required by `FileCaseSensitiveInfo`. + unsafe { + SetFileInformationByHandle( + HANDLE(handle.as_raw_handle()), + FileCaseSensitiveInfo, + (&raw const info).cast(), + info_size, + ) + } + .expect("set temporary directory case sensitivity"); } + #[cfg(target_os = "windows")] #[test] - fn windows_binary_match_path_distinguishes_case_sensitive_directory_entries() { - assert_ne!( - windows_network_binary_match_path_with_case_sensitivity( - r"C:\case-sensitive\Trusted.exe", - Some(true), - ), - windows_network_binary_match_path_with_case_sensitivity( - r"C:\case-sensitive\trusted.exe", - Some(true), - ), - "case-distinct files must not share network permissions" + fn windows_binary_matching_denies_case_mismatch_under_sensitive_ancestor() { + let root = tempfile::tempdir().expect("create temporary directory"); + set_windows_directory_case_sensitive(root.path(), true); + let containing_directory = root.path().join("TrustedTools"); + std::fs::create_dir(&containing_directory).expect("create binary directory"); + set_windows_directory_case_sensitive(&containing_directory, false); + let runtime_binary = containing_directory.join("curl.exe"); + std::fs::write(&runtime_binary, b"test executable").expect("create test executable"); + + assert_eq!(windows_directory_case_sensitive(root.path()), Some(true)); + assert_eq!( + windows_directory_case_sensitive(&containing_directory), + Some(false), + "the immediate parent must be insensitive to reproduce the old bug" + ); + assert_eq!( + windows_path_components_case_insensitive(&runtime_binary), + Some(false), + "a sensitive ancestor must keep the whole match case-exact" + ); + + let policy_binary = runtime_binary + .to_string_lossy() + .replace("TrustedTools", "trustedtools"); + assert_ne!(policy_binary, runtime_binary.to_string_lossy()); + let mut proto = openshell_policy::restrictive_default_policy(); + proto.network_policies.insert( + "windows_sensitive_ancestor".to_string(), + NetworkPolicyRule { + name: "windows_sensitive_ancestor".to_string(), + endpoints: vec![NetworkEndpoint { + host: "example.com".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: policy_binary, + }], + }, + ); + let engine = OpaEngine::from_proto(&proto).expect("load Windows policy"); + let decision = engine + .evaluate_network(&NetworkInput { + host: "example.com".to_string(), + port: 443, + binary_path: runtime_binary, + binary_sha256: "unused".to_string(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + }) + .expect("evaluate Windows policy"); + + assert!( + !decision.allowed, + "case-distinct paths under a sensitive ancestor must not share grants" ); } diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index c6dc767d95..47a565fba0 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -2766,6 +2766,7 @@ fn authorize_egress_intent_procfs( binary_pid, ancestors, cmdline_paths, + binary_match_paths: Vec::new(), } }; @@ -2836,6 +2837,7 @@ fn authorize_egress_intent_procfs( binary_pid: Some(binary_pid), ancestors, cmdline_paths, + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => deny( format!("policy evaluation error: {e}"), @@ -2893,6 +2895,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => EgressDecision { intent, @@ -2908,6 +2911,7 @@ fn evaluate_endpoint_only_opa(engine: &OpaEngine, intent: EgressIntent) -> Egres binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), }, } } @@ -2962,6 +2966,7 @@ fn authorize_egress_intent( binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(error) => EgressDecision { intent, @@ -2975,6 +2980,7 @@ fn authorize_egress_intent( binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: Vec::new(), }, } } @@ -4827,7 +4833,7 @@ async fn handle_forward_proxy( .await?; return Ok(()); } - let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, route.l7_policy_generation) { + let tunnel_engine = match relay::pin_l7_evaluator(&opa_engine, &decision) { Ok(engine) => engine, Err(e) => { warn!( @@ -8404,6 +8410,7 @@ network_policies: binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }; let route = query_l7_route_snapshot(&decision, host, port).expect("L7 route should match"); let config = select_l7_config_for_path(&route.configs, path) @@ -12383,6 +12390,7 @@ network_policies: binary_pid: Some(1), ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }; query_tls_mode(&decision, "203.0.113.10", 443) }; diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 314596b048..345360e5e2 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -156,6 +156,8 @@ pub(super) struct EgressDecision { pub(super) ancestors: Vec, /// Cmdline-derived absolute paths (for script detection). pub(super) cmdline_paths: Vec, + /// Runtime-derived path representations carried into pinned L7 evaluation. + pub(super) binary_match_paths: Vec, } #[cfg(test)] diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 0d710ce9a9..dc18e3484d 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -114,9 +114,12 @@ pub(super) fn pin_policy_generation( /// Clone an L7 evaluator for a relay or the forward HTTP single-request path. pub(super) fn pin_l7_evaluator( opa_engine: &OpaEngine, - expected_generation: u64, + decision: &EgressDecision, ) -> Result { - opa_engine.clone_engine_for_tunnel(expected_generation) + opa_engine.clone_engine_for_tunnel_with_match_paths( + decision.policy_generation, + decision.binary_match_paths.clone(), + ) } pub(super) fn validate_route_generation( @@ -157,7 +160,7 @@ pub(super) fn prepare_http_relay<'a>( } let policy = if let Some(route) = route.filter(|route| !route.configs.is_empty()) { - let evaluator = match pin_l7_evaluator(opa_engine, decision.policy_generation) { + let evaluator = match pin_l7_evaluator(opa_engine, decision) { Ok(evaluator) => evaluator, Err(error) => { emit_l7_tunnel_close_after_policy_change( @@ -357,6 +360,7 @@ mod tests { binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } @@ -423,6 +427,7 @@ network_policies: binary_pid: None, ancestors: Vec::new(), cmdline_paths: Vec::new(), + binary_match_paths: authorization.binary_match_paths.clone(), }; let route = query_l7_route_snapshot(&decision, "example.com", 80) .expect("REST endpoint should produce an inspected route"); diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 5948cd5224..2486b28394 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -20,6 +20,7 @@ fn allowed_decision(intent: EgressIntent) -> EgressDecision { binary_pid: Some(42), ancestors: vec![PathBuf::from("/usr/bin/sh")], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 64df8b0af9..fbe403a190 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -585,12 +585,11 @@ Identifies an executable that is permitted to use the associated endpoints. | `path` | string | Yes | Filesystem path to the executable. Supports glob patterns with `*` and `**`. For example, `/sandbox/.vscode-server/**` matches any executable under that directory tree. | On Windows, ordinary binary paths treat `/` and `\` as equivalent. ASCII case is -folded only when Windows confirms that the containing directory is -case-insensitive. Paths in case-sensitive directories, paths whose directory -semantics cannot be determined, and paths containing glob metacharacters retain -their case and therefore fail closed on case differences. Verbatim `\\?\` paths -retain their exact spelling and separators; policy authors should use the same -verbatim form reported by the runtime. +folded only when Windows confirms that every parent directory of the +runtime-observed executable uses case-insensitive lookup. If any component is +case-sensitive or cannot be checked, matching remains case-exact and fails +closed. Loading policy never opens a policy-authored path. UNC, device, and +verbatim namespace paths retain their exact spelling and separators. ## Network Middleware diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 60009e25ef..1c1a6d93cc 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -91,12 +91,12 @@ The proxy SHA256-hashes each binary on first use (trust-on-first-use). If someon | Recommendation | Scope binaries to the specific executables that need each endpoint. Use narrow globs when the exact path varies (for example, across Python virtual environments). | On Windows, OpenShell normalizes `/` and `\` for ordinary binary paths and folds -ASCII case only when the containing directory is confirmed case-insensitive. -Case-sensitive, indeterminate, and globbed paths retain case so a case-distinct -executable cannot inherit another executable's grant. Verbatim `\\?\` paths are -matched exactly without separator or case rewriting. Prefer ordinary absolute -paths; when a verbatim path is necessary, copy the exact runtime-reported form -into the policy. +ASCII case only when trusted runtime evidence confirms that every parent +directory uses case-insensitive lookup. A sensitive or indeterminate component +keeps the whole match case-exact, so a case-distinct executable cannot inherit +another executable's grant. Policy loading never opens policy-authored paths. +UNC, device, and verbatim namespace paths are matched exactly without separator +or case rewriting; prefer ordinary absolute paths. ### L4-Only vs L7 Inspection