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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/openshell-binary-identity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions crates/openshell-binary-identity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions crates/openshell-binary-identity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
77 changes: 77 additions & 0 deletions crates/openshell-binary-identity/src/windows.rs
Original file line number Diff line number Diff line change
@@ -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<bool> {
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::<FILE_CASE_SENSITIVE_INFO>()).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<bool> {
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<std::fs::File> {
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()
}
15 changes: 10 additions & 5 deletions crates/openshell-driver-mxc/examples/run-mxc-e2e.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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'
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 5 additions & 1 deletion crates/openshell-driver-mxc/tests/wxc_exec_real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-supervisor-network/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 43 additions & 7 deletions crates/openshell-supervisor-network/data/sandbox-policy.rego
Original file line number Diff line number Diff line change
Expand Up @@ -141,34 +141,70 @@ 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.
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) ---
Expand Down
12 changes: 9 additions & 3 deletions crates/openshell-supervisor-network/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"))?;
Expand All @@ -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,
Expand All @@ -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()
Expand Down
Loading
Loading