From bbc7bb5a01a6fa62eb517430bcc4cecdb882c7fd Mon Sep 17 00:00:00 2001 From: shailendras Date: Wed, 28 Oct 2026 11:24:14 -0700 Subject: [PATCH] fix(network): restore Windows binary path normalization Signed-off-by: shailendras (cherry picked from commit 8188a931bba467ea9f666cd4a95248efd22458d9) Signed-off-by: shailendras --- Cargo.lock | 1 + crates/openshell-binary-identity/Cargo.toml | 3 + crates/openshell-binary-identity/README.md | 7 + crates/openshell-binary-identity/src/lib.rs | 3 + .../openshell-binary-identity/src/windows.rs | 77 ++++ .../examples/run-mxc-e2e.ps1 | 15 +- .../tests/wxc_exec_real.rs | 6 +- crates/openshell-supervisor-network/README.md | 6 + .../data/sandbox-policy.rego | 50 ++- .../src/l7/relay.rs | 12 +- .../openshell-supervisor-network/src/opa.rs | 405 +++++++++++++++++- .../openshell-supervisor-network/src/proxy.rs | 11 +- .../src/proxy/egress.rs | 3 + .../src/proxy/relay.rs | 125 +++++- .../src/proxy/tests/compatibility.rs | 1 + docs/how-it-works/policies/network-rules.mdx | 6 + skills/generate-sandbox-policy/SKILL.md | 6 + 17 files changed, 710 insertions(+), 27 deletions(-) create mode 100644 crates/openshell-binary-identity/src/windows.rs diff --git a/Cargo.lock b/Cargo.lock index a438d9a878..8a67c75591 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4222,6 +4222,7 @@ version = "0.0.0" dependencies = [ "openshell-isolation-interface", "sha2 0.10.9", + "windows", ] [[package]] diff --git a/crates/openshell-binary-identity/Cargo.toml b/crates/openshell-binary-identity/Cargo.toml index a8b8714be4..30f1e72cf0 100644 --- a/crates/openshell-binary-identity/Cargo.toml +++ b/crates/openshell-binary-identity/Cargo.toml @@ -14,5 +14,8 @@ repository.workspace = true openshell-isolation-interface = { path = "../openshell-isolation-interface" } sha2 = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } + [lints] workspace = true diff --git a/crates/openshell-binary-identity/README.md b/crates/openshell-binary-identity/README.md index 472ddcd41a..71ee730599 100644 --- a/crates/openshell-binary-identity/README.md +++ b/crates/openshell-binary-identity/README.md @@ -18,3 +18,10 @@ The crate does not intercept connections, authenticate remote observers, or evaluate policy. The isolation backend remains responsible for binding the resolved identity to the active boundary and exact accepted connection before constructing `PendingTcpOpen`. + +On Windows, the crate also exposes a safe query for the case-sensitivity of +every parent directory of a trusted runtime executable path. Network policy +matching may fold ASCII case only when all of those directories confirm +case-insensitive lookup. Namespace paths and failed queries retain exact case +matching. The query never receives policy-authored paths, and matching does not +change the original path used for executable hashing. diff --git a/crates/openshell-binary-identity/src/lib.rs b/crates/openshell-binary-identity/src/lib.rs index 3b42e99b21..c016d780aa 100644 --- a/crates/openshell-binary-identity/src/lib.rs +++ b/crates/openshell-binary-identity/src/lib.rs @@ -9,6 +9,9 @@ //! collects its process ancestry. Backends bind the returned identity to the //! intercepted connection before constructing a `MediatedConnection`. +#[cfg(target_os = "windows")] +pub mod windows; + use openshell_isolation_interface::contract::{BinaryIdentity, ResolveError}; #[cfg(target_os = "linux")] use openshell_isolation_interface::contract::{ExecutableIdentity, Sha256Digest}; diff --git a/crates/openshell-binary-identity/src/windows.rs b/crates/openshell-binary-identity/src/windows.rs new file mode 100644 index 0000000000..904562fe02 --- /dev/null +++ b/crates/openshell-binary-identity/src/windows.rs @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Trusted Windows runtime-path attributes for executable policy matching. + +use std::path::Path; + +#[allow(unsafe_code)] +fn windows_directory_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, + }; + + 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 + // 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) +} + +/// Check every parent of a trusted, ordinary absolute executable path. +/// +/// Returns `None` for namespace paths or when any directory cannot be queried. +/// Callers must retain exact case matching unless this returns `Some(true)`. +/// Never call this with policy-authored paths. +#[must_use] +pub fn path_components_case_insensitive(path: &Path) -> Option { + let text = path.to_string_lossy(); + let bytes = text.as_bytes(); + if text.contains(['*', '?']) + || (bytes.len() >= 2 + && matches!(bytes[0], b'/' | b'\\') + && matches!(bytes[1], b'/' | b'\\')) + || 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) +} + +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() +} diff --git a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 index 0b5709d7d4..4874e27077 100644 --- a/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 +++ b/crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1 @@ -38,6 +38,7 @@ # service-forwarding - named HTTP service routing and endpoint deletion. # host-service - workload reaches an HTTP service on host 127.0.0.1. # https-ca - opt-in outbound HTTPS using the injected proxy CA. +# socket-owner-normalized - child curl matches equivalent Windows policy paths. [CmdletBinding()] param( @@ -806,14 +807,14 @@ try { Description = 'curl trusts the injected CA and observes the OpenShell HTTPS proxy certificate' } } - if ($Scenario -in @('socket-owner-allow', 'socket-owner-deny')) { + if ($Scenario -in @('socket-owner-allow', 'socket-owner-normalized', 'socket-owner-deny')) { $allScenarios += @{ Name = $Scenario; PolicyFile = Join-Path $policyDir 'https-ca.yaml' SandboxId = 'own'; Backends = 'process_container'; Kind = 'socket-owner' PosTarget = Join-Path $DemoDir 'example.html' ControlTarget = Join-Path $DemoDir 'owner-launched.txt' ErrorTarget = Join-Path $DemoDir 'curl-stderr.txt' - ExpectAllowed = ($Scenario -eq 'socket-owner-allow') + ExpectAllowed = ($Scenario -ne 'socket-owner-deny') Description = 'network policy follows child curl rather than parent cmd' } } @@ -890,9 +891,13 @@ try { } $policyText = $policyText.Replace('__HOST_SERVICE_PORT__', "$hostPort") } - # Preserve the exact Windows executable path used in the launch; - # network binary identities are compared as strings, not FS paths. - $policyText = $policyText.Replace('__CURL_EXE__', $curlExe.Replace("'", "''")) + # The normalization regression changes only the policy spelling; + # the launched executable and its hash retain their original path. + $policyCurlExe = $curlExe + if ($sc.Name -eq 'socket-owner-normalized') { + $policyCurlExe = $curlExe.Replace('\', '/').ToUpperInvariant() + } + $policyText = $policyText.Replace('__CURL_EXE__', $policyCurlExe.Replace("'", "''")) if ($sc.Kind -eq 'socket-owner' -and -not $sc.ExpectAllowed) { $policyText = $policyText.Replace($curlExe, $cmdExe) } diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index 73d17f6ea6..5a0788a263 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -331,7 +331,11 @@ fn pc_proxy_scopes_network_policy_to_socket_owner() { let wxc = wxc_path().expect("real MXC socket-owner qualification requires wxc-exec"); probe_processcontainer(&wxc).expect("native ProcessContainer must be live"); probe_processcontainer_host_loopback(&wxc).expect("native governed loopback must be available"); - for scenario in ["socket-owner-allow", "socket-owner-deny"] { + for scenario in [ + "socket-owner-allow", + "socket-owner-normalized", + "socket-owner-deny", + ] { let directory = tempfile::tempdir().unwrap(); let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let target = if cfg!(target_arch = "aarch64") { diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index 30f404752a..f76ad11e41 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -3,6 +3,12 @@ This crate forbids unsafe code, including its local tests. Typed socket APIs provide original-destination lookup for transparent proxying. +Windows binary-policy comparison normalizes ordinary path separators. ASCII +case folding requires trusted directory evidence from `openshell-binary-identity`. +The L4 decision carries that evidence into its generation-pinned L7 evaluator, +so both layers use the same executable identity. Original paths remain intact +for hashing; policy loading never opens authored paths. + Socket attribution tests pass a duplicate connected socket as a spawned child's stdin. Re-executing the test binary checks multiple holders with the same binary identity; executing a different binary checks ambiguous shared-socket identity. diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index 3c98c7c2e9..810fe6c4a5 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 31ac623352..a0d3882256 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -3364,11 +3364,12 @@ fn admitted_token_grant_owners_once( if engine.is_stale() { return Err(miette!("policy changed before token grant selection")); } + let input = l7_request_policy_input(engine, ctx, request); let mut engine = engine .engine() .lock() .map_err(|_| miette!("OPA engine lock poisoned"))?; - crate::opa::set_regorus_input(&mut engine, l7_request_policy_input(ctx, request))?; + crate::opa::set_regorus_input(&mut engine, input)?; let owners = engine .eval_rule("data.openshell.sandbox.allowed_token_grant_owners".into()) .map_err(|error| miette!("{error}"))?; @@ -3384,13 +3385,18 @@ fn admitted_token_grant_owners_once( .collect() } -fn l7_request_policy_input(ctx: &L7EvalContext, request: &L7RequestInfo) -> serde_json::Value { +fn l7_request_policy_input( + engine: &TunnelPolicyEngine, + ctx: &L7EvalContext, + request: &L7RequestInfo, +) -> serde_json::Value { serde_json::json!({ "network": { "host": ctx.host, "port": ctx.port }, "exec": { "path": ctx.binary_path, "ancestors": ctx.ancestors, "cmdline_paths": ctx.cmdline_paths, + "match_paths": engine.binary_match_paths(), }, "request": { "method": request.action, @@ -3415,7 +3421,7 @@ fn evaluate_l7_request_once( )); } - let input = l7_request_policy_input(ctx, request); + let input = l7_request_policy_input(engine, ctx, request); let mut engine = engine .engine() diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 6c28c3e11d..94b79d55f6 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -112,6 +112,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. @@ -256,6 +257,7 @@ pub struct TunnelPolicyEngine { generation_guard: PolicyGenerationGuard, middleware_runner: ChainRunner, websocket_assembly_budget: crate::l7::websocket::WebSocketAssemblyBudget, + binary_match_paths: Vec, } impl TunnelPolicyEngine { @@ -283,6 +285,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 { @@ -499,6 +505,7 @@ impl OpaEngine { let mut data: serde_json::Value = serde_json::from_str(&data_json_str) .map_err(|_| miette::miette!("internal: failed to parse proto JSON"))?; inject_runtime_policy_data(&mut data, require_binary_identity); + normalize_network_binary_paths(&mut data); normalize_endpoint_protocols(&mut data); // Validate BEFORE expanding presets @@ -607,7 +614,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 @@ -627,6 +635,7 @@ impl OpaEngine { matched_endpoints: Vec::new(), exact_declared_endpoint_host: false, generation, + binary_match_paths, }); } @@ -666,6 +675,7 @@ impl OpaEngine { matched_endpoints, exact_declared_endpoint_host, generation, + binary_match_paths, }) } @@ -1118,6 +1128,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() @@ -1137,6 +1155,7 @@ impl OpaEngine { }, middleware_runner: self.middleware_runner()?, websocket_assembly_budget: self.websocket_assembly_budget(), + binary_match_paths, }) } } @@ -1196,10 +1215,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| p.to_string_lossy().into_owned()) + .skip(1) + .map(|path| path.path.as_str()) .collect(); let cmdline_strs: Vec = input .cmdline_paths @@ -1208,9 +1248,10 @@ fn network_input_json(input: &NetworkInput) -> serde_json::Value { .collect(); serde_json::json!({ "exec": { - "path": input.binary_path.to_string_lossy(), + "path": &binary.path, "ancestors": ancestor_strs, "cmdline_paths": cmdline_strs, + "match_paths": match_paths, }, "network": { "host": input.host, @@ -1219,6 +1260,61 @@ 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. 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")] + { + 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 { + if is_windows_namespace_path(path) { + return path.to_owned(); + } + path.replace('\\', "/") +} + +#[cfg(any(target_os = "windows", test))] +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'\\') +} + +#[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, +} + +fn network_binary_path_evidence(path: &Path) -> NetworkBinaryPathEvidence { + let normalized = network_binary_match_path(path); + #[cfg(target_os = "windows")] + let ascii_case_folded = + openshell_binary_identity::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, + } +} + /// 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 @@ -1672,6 +1768,7 @@ fn preprocess_yaml_data( raw_schema::validate_network_settings(&data)?; validate_opa_static_settings(&mut 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. @@ -1776,6 +1873,43 @@ fn normalize_endpoint_protocols(data: &mut serde_json::Value) { } } +/// 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") + .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(binary) = binary.as_object_mut() else { + continue; + }; + let Some(value) = binary.get("path").and_then(serde_json::Value::as_str) else { + continue; + }; + 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()); + } + } +} + /// Normalize endpoint port/ports in JSON data. /// /// YAML policies may use `port: N` (single) or `ports: [N, M]` (multi). @@ -2665,6 +2799,127 @@ mod tests { } } + #[test] + fn windows_binary_match_path_normalizes_separators_preserving_case() { + 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") + ); + } + + #[test] + 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")] + fn set_windows_directory_case_sensitive(path: &Path, enabled: bool) { + let output = std::process::Command::new("fsutil.exe") + .args(["file", "setCaseSensitiveInfo"]) + .arg(path) + .arg(if enabled { "enable" } else { "disable" }) + .output() + .expect("run Windows case-sensitivity fixture setup"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[cfg(target_os = "windows")] + #[test] + 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!( + openshell_binary_identity::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" + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_binary_matching_keeps_case_exact_when_directory_query_fails() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("absent-parent").join("Tool.exe"); + let data = serde_json::json!({ + "network_policies": { + "missing_directory": { + "endpoints": [{"host": "example.com", "port": 443}], + "binaries": [{"path": path.to_string_lossy().to_ascii_lowercase()}], + }, + }, + }); + let engine = OpaEngine::from_strings(TEST_POLICY, &data.to_string()).unwrap(); + let input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: path, + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!( + !engine.evaluate_network(&input).unwrap().allowed, + "an unverified directory must not enable case-insensitive grants" + ); + } + #[test] fn load_diagnostics_enforce_item_and_byte_limits_with_omission() { const LONG_CATEGORY: &str = concat!( @@ -5115,6 +5370,146 @@ 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 + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_binary_matching_covers_ancestors_and_globs_without_cmdline_grants() { + for configured in [ + r"C:\WINDOWS\SYSTEM32\CURL.EXE", + r"C:\WINDOWS\SYSTEM32\*.EXE", + ] { + let engine = OpaEngine::from_strings( + TEST_POLICY, + &format!( + "network_policies:\n windows_binary:\n endpoints:\n - {{host: example.com, port: 443}}\n binaries:\n - {{path: '{configured}'}}\n" + ), + ) + .expect("load Windows policy"); + let executable = PathBuf::from("c:/windows/system32/curl.exe"); + let mut input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from("C:/unlisted/child.exe"), + binary_sha256: "unused".into(), + ancestors: vec![executable.clone()], + cmdline_paths: vec![], + }; + assert!(engine.evaluate_network(&input).unwrap().allowed); + input.ancestors.clear(); + input.cmdline_paths.push(executable); + assert!( + !engine.evaluate_network(&input).unwrap().allowed, + "command-line paths must not authorize a connection for {configured}" + ); + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn non_windows_binary_matching_preserves_case_and_separators() { + let engine = OpaEngine::from_strings( + TEST_POLICY, + "network_policies:\n exact:\n endpoints:\n - {host: example.com, port: 443}\n binaries:\n - {path: '/opt/Tools/curl'}\n", + ) + .unwrap(); + for (path, allowed) in [ + ("/opt/Tools/curl", true), + ("/opt/tools/curl", false), + (r"\opt\Tools\curl", false), + ] { + let input = NetworkInput { + host: "example.com".into(), + port: 443, + binary_path: PathBuf::from(path), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert_eq!( + engine.evaluate_network(&input).unwrap().allowed, + allowed, + "{path}" + ); + } + } + #[test] fn from_proto_denies_unmatched_request() { let proto = test_proto(); diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 91b77135f6..ceaab9bffa 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3599,6 +3599,7 @@ fn authorize_egress_intent_inner( binary_pid, ancestors, cmdline_paths, + binary_match_paths: Vec::new(), } }; @@ -3656,6 +3657,7 @@ fn authorize_egress_intent_inner( binary_pid: Some(binary_pid), ancestors, cmdline_paths, + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => deny( format!("policy evaluation error: {e}"), @@ -3704,6 +3706,7 @@ fn evaluate_endpoint_only_opa_inner(engine: &OpaEngine, intent: EgressIntent) -> binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(e) => EgressDecision { intent, @@ -3719,6 +3722,7 @@ fn evaluate_endpoint_only_opa_inner(engine: &OpaEngine, intent: EgressIntent) -> binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), }, } } @@ -3780,6 +3784,7 @@ fn authorize_supplied_identity_with_denial_inner( binary_pid: None, ancestors, cmdline_paths, + binary_match_paths: Vec::new(), }; let identity = match identity { @@ -3839,6 +3844,7 @@ fn authorize_supplied_identity_with_denial_inner( binary_pid: None, ancestors: ancestor_paths, cmdline_paths: identity.cmdline_paths.clone(), + binary_match_paths: authorization.binary_match_paths.clone(), }, Err(error) => deny( format!("policy evaluation error: {error}"), @@ -3876,6 +3882,7 @@ fn authorize_egress_intent_inner( binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } @@ -5730,7 +5737,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!( @@ -11180,6 +11187,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) @@ -14773,6 +14781,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 77b89a9b7e..44fe4cc81a 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -168,6 +168,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, } pub(super) fn connect_span(intent: &EgressIntent) -> tracing::Span { @@ -254,6 +256,7 @@ mod tests { binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 54c93586cb..6f77208e63 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 @@ -115,9 +115,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( @@ -158,7 +161,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( @@ -360,8 +363,16 @@ fn emit_stale_relay_close(request: &L7EvalContext, guard: &PolicyGenerationGuard #[cfg(test)] mod tests { + #[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"); const EMPTY_POLICY_DATA: &str = "network_policies: {}\n"; @@ -379,6 +390,7 @@ mod tests { binary_pid: None, ancestors: vec![], cmdline_paths: vec![], + binary_match_paths: Vec::new(), } } @@ -500,6 +512,109 @@ mod tests { assert_response_lifecycle(caller.unwrap(), client.unwrap()).await; } + #[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(), + 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"); + 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/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index a039cfbc14..60e055df4b 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/how-it-works/policies/network-rules.mdx b/docs/how-it-works/policies/network-rules.mdx index 811d920922..2fbd65ebf5 100644 --- a/docs/how-it-works/policies/network-rules.mdx +++ b/docs/how-it-works/policies/network-rules.mdx @@ -76,6 +76,12 @@ too. A rule with an empty `binaries` list matches no binary and allows nothing. OpenShell records a hash of each executable the first time it takes part in a connection, and denies later connections if the file at that path changes. +On Windows, ordinary executable paths treat `/` and `\` as equivalent. ASCII +case differences match only when Windows confirms that every parent directory +of the runtime executable uses case-insensitive lookup. Otherwise, use exact +case. UNC, device, and verbatim namespace paths retain their exact spelling and +separators. These comparisons do not change the path used for executable hashing. + ### Connection and Request Checks OpenShell checks network traffic in two stages: diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index b2390654ae..a7a57a28ef 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -91,6 +91,12 @@ Regardless of tier, extract (or infer) these from the user's description: If the host and access level are clear but binaries are not specified, ask the user which binary or process will be making the requests. Suggest common defaults like `/usr/bin/curl`, `/usr/local/bin/claude`, etc. +For Windows binaries, use ordinary absolute executable paths. `/` and `\` are +equivalent; ASCII case differences match only when Windows confirms that every +parent directory of the runtime executable is case-insensitive. Keep exact case +when that cannot be established. UNC, device, and verbatim namespace paths retain +exact spelling. See the published policy guide's Binary Matching section. + ## Step 2: Refine Scope (Clarification Loop) Before generating the policy, **proactively ask clarifying questions** to help the user scope the policy down as narrowly as possible. The goal is the most restrictive policy that still satisfies the user's needs.